1use rust_decimal::Decimal;
30use sqlx::{PgConnection, Row};
31use uuid::Uuid;
32
33pub struct MoveStateRow {
35 pub id: Uuid,
36 pub item_id: Uuid,
37 pub state: String,
38 pub demand_qty: Decimal,
39 pub quantity: Decimal,
40 pub is_inventory: bool,
41}
42
43pub struct TransferHeaderRow {
45 pub id: Uuid,
46 pub name: String,
47 pub origin: Option<String>,
48 pub picking_type_id: Uuid,
49 pub location_id: Uuid,
50 pub location_dest_id: Uuid,
51 pub state: String,
52 pub date_done: Option<chrono::DateTime<chrono::Utc>>,
53}
54
55pub struct OperationTypeFacts {
59 pub id: Uuid,
60 pub code: String,
62 pub reservation_method: String,
64 pub create_backorder: String,
66 pub sequence_code: String,
69 pub move_type: String,
72}
73
74pub struct AdjustmentMoveRow {
77 pub id: Uuid,
78 pub state: String,
80 pub date: Option<chrono::DateTime<chrono::Utc>>,
82}
83
84pub struct NewPickingRow<'a> {
88 pub id: Uuid,
89 pub name: &'a str,
90 pub origin: Option<&'a str>,
91 pub picking_type_id: Uuid,
92 pub location_id: Uuid,
93 pub location_dest_id: Uuid,
94 pub partner_id: Option<Uuid>,
95 pub move_type: &'a str,
96}
97
98#[derive(Default)]
102pub struct StockPickingRepository;
103
104impl StockPickingRepository {
105 pub fn new() -> Self {
106 Self
107 }
108
109 pub async fn insert_transfer(
113 &self,
114 conn: &mut PgConnection,
115 t: &NewPickingRow<'_>,
116 ) -> Result<(), sqlx::Error> {
117 sqlx::query(
118 r#"INSERT INTO inventory.transfers
119 (id, name, origin, priority, picking_type_id, location_id, location_dest_id,
120 partner_id, move_type, scheduled_date, state)
121 VALUES ($1, $2, $3, 'normal', $4, $5, $6, $7, $8::move_type, NOW(),
122 'draft'::transfer_state)"#,
123 )
124 .bind(t.id)
125 .bind(t.name)
126 .bind(t.origin)
127 .bind(t.picking_type_id)
128 .bind(t.location_id)
129 .bind(t.location_dest_id)
130 .bind(t.partner_id)
131 .bind(t.move_type)
132 .execute(conn)
133 .await?;
134 Ok(())
135 }
136
137 pub async fn fetch_transfer(
140 &self,
141 conn: &mut PgConnection,
142 transfer_id: Uuid,
143 ) -> Result<Option<TransferHeaderRow>, sqlx::Error> {
144 let row = sqlx::query(
145 r#"SELECT id, name, origin, picking_type_id, location_id, location_dest_id,
146 state::text AS state, date_done
147 FROM inventory.transfers
148 WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
149 )
150 .bind(transfer_id)
151 .fetch_optional(conn)
152 .await?;
153 Ok(row.map(|r| TransferHeaderRow {
154 id: r.get("id"),
155 name: r.get("name"),
156 origin: r.get("origin"),
157 picking_type_id: r.get("picking_type_id"),
158 location_id: r.get("location_id"),
159 location_dest_id: r.get("location_dest_id"),
160 state: r.get("state"),
161 date_done: r.get("date_done"),
162 }))
163 }
164
165 pub async fn fetch_moves_of_transfer(
167 &self,
168 conn: &mut PgConnection,
169 transfer_id: Uuid,
170 ) -> Result<Vec<MoveStateRow>, sqlx::Error> {
171 let rows = sqlx::query(
172 r#"SELECT id, item_id, state::text AS state, demand_qty, quantity, is_inventory
173 FROM inventory.stock_moves
174 WHERE picking_id = $1 AND (metadata->>'deleted_at') IS NULL
175 ORDER BY create_date, id"#,
176 )
177 .bind(transfer_id)
178 .fetch_all(conn)
179 .await?;
180 Ok(rows
181 .iter()
182 .map(|r| MoveStateRow {
183 id: r.get("id"),
184 item_id: r.get("item_id"),
185 state: r.get("state"),
186 demand_qty: r.get("demand_qty"),
187 quantity: r.get("quantity"),
188 is_inventory: r.get("is_inventory"),
189 })
190 .collect())
191 }
192
193 pub async fn move_ids_of_transfer(
195 &self,
196 conn: &mut PgConnection,
197 transfer_id: Uuid,
198 ) -> Result<Vec<Uuid>, sqlx::Error> {
199 let rows = sqlx::query(
200 r#"SELECT id FROM inventory.stock_moves
201 WHERE picking_id = $1 AND (metadata->>'deleted_at') IS NULL"#,
202 )
203 .bind(transfer_id)
204 .fetch_all(conn)
205 .await?;
206 Ok(rows.iter().map(|r| r.get("id")).collect())
207 }
208
209 pub async fn fetch_operation_type(
211 &self,
212 conn: &mut PgConnection,
213 operation_type_id: Uuid,
214 ) -> Result<Option<OperationTypeFacts>, sqlx::Error> {
215 let row = sqlx::query(
216 r#"SELECT id, code::text AS code, reservation_method::text AS reservation_method,
217 create_backorder::text AS create_backorder, sequence_code,
218 move_type::text AS move_type
219 FROM inventory.operation_types
220 WHERE id = $1 AND active AND (metadata->>'deleted_at') IS NULL"#,
221 )
222 .bind(operation_type_id)
223 .fetch_optional(conn)
224 .await?;
225 Ok(row.map(|r| OperationTypeFacts {
226 id: r.get("id"),
227 code: r.get("code"),
228 reservation_method: r.get("reservation_method"),
229 create_backorder: r.get("create_backorder"),
230 sequence_code: r.get("sequence_code"),
231 move_type: r.get("move_type"),
232 }))
233 }
234
235 pub async fn find_open_group_picking(
242 &self,
243 conn: &mut PgConnection,
244 picking_type_id: Uuid,
245 location_id: Uuid,
246 location_dest_id: Uuid,
247 partner_id: Option<Uuid>,
248 origin: Option<&str>,
249 ) -> Result<Option<Uuid>, sqlx::Error> {
250 let row = sqlx::query_scalar::<_, Uuid>(
251 r#"SELECT id FROM inventory.transfers
252 WHERE picking_type_id = $1
253 AND location_id = $2
254 AND location_dest_id = $3
255 AND partner_id IS NOT DISTINCT FROM $4
256 AND origin IS NOT DISTINCT FROM $5
257 AND state NOT IN ('done'::transfer_state, 'cancel'::transfer_state)
258 AND (metadata->>'deleted_at') IS NULL
259 ORDER BY scheduled_date DESC, id
260 LIMIT 1"#,
261 )
262 .bind(picking_type_id)
263 .bind(location_id)
264 .bind(location_dest_id)
265 .bind(partner_id)
266 .bind(origin)
267 .fetch_optional(conn)
268 .await?;
269 Ok(row)
270 }
271
272 pub async fn resolve_internal_location(
276 &self,
277 conn: &mut PgConnection,
278 warehouse_id: Uuid,
279 ) -> Result<Option<Uuid>, sqlx::Error> {
280 let row = sqlx::query(
281 r#"SELECT id FROM inventory.locations
282 WHERE warehouse_id = $1 AND usage = 'internal'::location_usage AND active
283 AND (metadata->>'deleted_at') IS NULL
284 ORDER BY parent_path ASC NULLS FIRST, name ASC LIMIT 1"#,
285 )
286 .bind(warehouse_id)
287 .fetch_optional(conn)
288 .await?;
289 Ok(row.map(|r| r.get("id")))
290 }
291
292 pub async fn resolve_inventory_loss_location(
295 &self,
296 conn: &mut PgConnection,
297 ) -> Result<Option<Uuid>, sqlx::Error> {
298 let row = sqlx::query(
299 r#"SELECT id FROM inventory.locations
300 WHERE usage = 'inventory'::location_usage AND active
301 AND (metadata->>'deleted_at') IS NULL
302 ORDER BY name ASC LIMIT 1"#,
303 )
304 .fetch_optional(conn)
305 .await?;
306 Ok(row.map(|r| r.get("id")))
307 }
308
309 pub async fn ensure_internal_location(
314 &self,
315 conn: &mut PgConnection,
316 warehouse_id: Uuid,
317 ) -> Result<Uuid, sqlx::Error> {
318 if let Some(id) = self.resolve_internal_location(conn, warehouse_id).await? {
319 return Ok(id);
320 }
321 let id = Uuid::new_v4();
322 sqlx::query(
323 r#"INSERT INTO inventory.locations
324 (id, name, complete_name, usage, active, warehouse_id, parent_path)
325 VALUES ($1, 'Stock', 'Stock', 'internal'::location_usage, TRUE, $2, $1::text)"#,
326 )
327 .bind(id)
328 .bind(warehouse_id)
329 .execute(conn)
330 .await?;
331 Ok(id)
332 }
333
334 pub async fn ensure_inventory_loss_location(
338 &self,
339 conn: &mut PgConnection,
340 ) -> Result<Uuid, sqlx::Error> {
341 if let Some(id) = self.resolve_inventory_loss_location(conn).await? {
342 return Ok(id);
343 }
344 let id = Uuid::new_v4();
345 sqlx::query(
346 r#"INSERT INTO inventory.locations
347 (id, name, complete_name, usage, active, parent_path)
348 VALUES ($1, 'Inventory adjustment', 'Inventory adjustment',
349 'inventory'::location_usage, TRUE, $1::text)"#,
350 )
351 .bind(id)
352 .execute(conn)
353 .await?;
354 Ok(id)
355 }
356
357 pub async fn resolve_partner_location(
360 &self,
361 conn: &mut PgConnection,
362 usage: &str,
363 ) -> Result<Option<Uuid>, sqlx::Error> {
364 let row = sqlx::query(
365 r#"SELECT id FROM inventory.locations
366 WHERE usage = $1::location_usage AND active
367 AND (metadata->>'deleted_at') IS NULL
368 ORDER BY name ASC LIMIT 1"#,
369 )
370 .bind(usage)
371 .fetch_optional(conn)
372 .await?;
373 Ok(row.map(|r| r.get("id")))
374 }
375
376 pub async fn ensure_partner_location(
381 &self,
382 conn: &mut PgConnection,
383 usage: &str,
384 ) -> Result<Uuid, sqlx::Error> {
385 if let Some(id) = self.resolve_partner_location(conn, usage).await? {
386 return Ok(id);
387 }
388 let id = Uuid::new_v4();
389 let name = if usage == "supplier" { "Suppliers" } else { "Customers" };
390 sqlx::query(
391 r#"INSERT INTO inventory.locations
392 (id, name, complete_name, usage, active, parent_path)
393 VALUES ($1, $2, $2, $3::location_usage, TRUE, $1::text)"#,
394 )
395 .bind(id)
396 .bind(name)
397 .bind(usage)
398 .execute(conn)
399 .await?;
400 Ok(id)
401 }
402
403 pub async fn ensure_quant_surface(
413 &self,
414 conn: &mut PgConnection,
415 item_id: Uuid,
416 location_id: Uuid,
417 warehouse_id: Option<Uuid>,
418 ) -> Result<(), sqlx::Error> {
419 sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
420 .bind(format!("quant:{item_id}:{location_id}:NULL:NULL:NULL"))
421 .execute(&mut *conn)
422 .await?;
423 sqlx::query(
427 r#"INSERT INTO inventory.stock_quants
428 (id, item_id, location_id, quantity, reserved_quantity, available_quantity,
429 in_date)
430 SELECT $1, $2, $3,
431 COALESCE((SELECT b.actual_qty FROM inventory.bins b
432 WHERE b.item_id = $2
433 AND b.warehouse_id = $4::uuid
434 AND (b.metadata->>'deleted_at') IS NULL), 0),
435 0,
436 COALESCE((SELECT b.actual_qty FROM inventory.bins b
437 WHERE b.item_id = $2
438 AND b.warehouse_id = $4::uuid
439 AND (b.metadata->>'deleted_at') IS NULL), 0),
440 NOW()
441 WHERE $4::uuid IS NOT NULL
442 AND NOT EXISTS (
443 SELECT 1 FROM inventory.stock_quants q
444 WHERE q.item_id = $2 AND q.location_id = $3
445 AND q.lot_id IS NULL AND q.package_id IS NULL AND q.owner_id IS NULL
446 AND (q.metadata->>'deleted_at') IS NULL
447 )"#,
448 )
449 .bind(Uuid::new_v4())
450 .bind(item_id)
451 .bind(location_id)
452 .bind(warehouse_id)
453 .execute(&mut *conn)
454 .await?;
455 sqlx::query(
458 r#"INSERT INTO inventory.stock_quants
459 (id, item_id, location_id, quantity, reserved_quantity, available_quantity,
460 in_date)
461 SELECT $1, $2, $3, 0, 0, 0, NOW()
462 WHERE $4::uuid IS NULL
463 AND NOT EXISTS (
464 SELECT 1 FROM inventory.stock_quants q
465 WHERE q.item_id = $2 AND q.location_id = $3
466 AND q.lot_id IS NULL AND q.package_id IS NULL AND q.owner_id IS NULL
467 AND (q.metadata->>'deleted_at') IS NULL
468 )"#,
469 )
470 .bind(Uuid::new_v4())
471 .bind(item_id)
472 .bind(location_id)
473 .bind(warehouse_id)
474 .execute(&mut *conn)
475 .await?;
476 Ok(())
477 }
478
479 pub async fn find_adjustment_moves(
484 &self,
485 conn: &mut PgConnection,
486 item_id: Uuid,
487 location_a: Uuid,
488 location_b: Uuid,
489 ) -> Result<Vec<AdjustmentMoveRow>, sqlx::Error> {
490 let rows = sqlx::query(
491 r#"SELECT id, state::text AS state, date
492 FROM inventory.stock_moves
493 WHERE item_id = $1 AND is_inventory
494 AND (metadata->>'deleted_at') IS NULL
495 AND ((location_id = $2 AND location_dest_id = $3)
496 OR (location_id = $3 AND location_dest_id = $2))
497 ORDER BY (state NOT IN ('done'::move_state, 'cancel'::move_state)) DESC,
498 date DESC NULLS LAST, create_date DESC"#,
499 )
500 .bind(item_id)
501 .bind(location_a)
502 .bind(location_b)
503 .fetch_all(conn)
504 .await?;
505 Ok(rows
506 .iter()
507 .map(|r| AdjustmentMoveRow { id: r.get("id"), state: r.get("state"), date: r.get("date") })
508 .collect())
509 }
510}