1use rust_decimal::Decimal;
19use sqlx::{Executor, Postgres, Row};
20use uuid::Uuid;
21
22#[derive(Debug, Clone)]
24pub struct RuleRow {
25 pub id: Uuid,
26 pub name: String,
27 pub sequence: i32,
28 pub action: String,
29 pub auto: String,
30 pub procure_method: String,
31 pub delay: i32,
32 pub location_src_id: Option<Uuid>,
33 pub location_dest_id: Uuid,
34 pub picking_type_id: Uuid,
35 pub route_id: Uuid,
36 pub warehouse_id: Option<Uuid>,
37 pub propagate_cancel: bool,
38}
39
40#[derive(Debug, Clone)]
42pub struct OrderpointRow {
43 pub id: Uuid,
44 pub name: String,
45 pub trigger: String,
46 pub item_id: Uuid,
47 pub location_id: Uuid,
48 pub warehouse_id: Uuid,
49 pub item_min_qty: Decimal,
50 pub item_max_qty: Decimal,
51 pub route_id: Option<Uuid>,
52 pub qty_to_order_manual: Decimal,
53}
54
55#[derive(Debug, Clone)]
57pub struct ClaimedMoveRow {
58 pub id: Uuid,
59 pub name: String,
60 pub item_id: Uuid,
61 pub state: String,
62}
63
64#[derive(Debug, Clone, Copy, Default)]
67pub struct ForecastComponents {
68 pub on_hand: Decimal,
69 pub incoming: Decimal,
70 pub outgoing: Decimal,
71}
72
73#[derive(Debug, Clone, Copy)]
75pub struct OrderpointComputes {
76 pub qty_on_hand: Decimal,
77 pub qty_forecast: Decimal,
78 pub qty_to_order: Decimal,
79 pub lead_days: Decimal,
80 pub deadline_date: Option<chrono::NaiveDate>,
81}
82
83#[derive(Debug, Clone)]
85pub struct NewMoveRow {
86 pub name: String,
87 pub item_id: Uuid,
88 pub demand_qty: Decimal,
89 pub procure_method: String,
90 pub origin: String,
91 pub location_id: Uuid,
92 pub location_dest_id: Uuid,
93 pub rule_id: Option<Uuid>,
94 pub warehouse_id: Option<Uuid>,
95 pub orderpoint_id: Option<Uuid>,
96 pub move_orig_ids: Vec<Uuid>,
97 pub move_dest_ids: Vec<Uuid>,
98 pub propagate_cancel: bool,
99}
100
101fn rule_row(m: sqlx::postgres::PgRow) -> RuleRow {
102 RuleRow {
103 id: m.get("id"),
104 name: m.get("name"),
105 sequence: m.get("sequence"),
106 action: m.get("action"),
107 auto: m.get("auto"),
108 procure_method: m.get("procure_method"),
109 delay: m.get("delay"),
110 location_src_id: m.get("location_src_id"),
111 location_dest_id: m.get("location_dest_id"),
112 picking_type_id: m.get("picking_type_id"),
113 route_id: m.get("route_id"),
114 warehouse_id: m.get("warehouse_id"),
115 propagate_cancel: m.get("propagate_cancel"),
116 }
117}
118
119const SUBTREE_SQL: &str =
124 "SELECT l.id FROM inventory.locations l, demand d WHERE l.parent_path LIKE d.parent_path || '%'";
125
126#[derive(Debug, Clone, Copy, Default)]
129pub struct ProcurementRepository;
130
131impl ProcurementRepository {
132 pub fn new() -> Self {
133 Self
134 }
135
136 pub async fn search_rule<'e, E>(
147 executor: E,
148 demand_location_id: Uuid,
149 route_ids: Option<Vec<Uuid>>,
150 ) -> Result<Option<RuleRow>, sqlx::Error>
151 where
152 E: Executor<'e, Database = Postgres>,
153 {
154 let row = sqlx::query(
155 r#"SELECT r.id, r.name, r.sequence, r.action::text AS action, r.auto::text AS auto,
156 r.procure_method::text AS procure_method, r.delay, r.location_src_id,
157 r.location_dest_id, r.picking_type_id, r.route_id, r.warehouse_id,
158 r.propagate_cancel
159 FROM inventory.route_rules r
160 JOIN inventory.routes rt ON rt.id = r.route_id
161 AND rt.active
162 AND (rt.metadata->>'deleted_at') IS NULL
163 JOIN inventory.locations dest ON dest.id = r.location_dest_id
164 JOIN inventory.locations demand ON demand.id = $1
165 AND (demand.metadata->>'deleted_at') IS NULL
166 AND demand.parent_path LIKE dest.parent_path || '%'
167 WHERE r.active
168 AND r.action IN ('pull', 'pull_push')
169 AND (r.metadata->>'deleted_at') IS NULL
170 AND ($2::uuid[] IS NULL OR r.route_id = ANY($2))
171 ORDER BY r.sequence DESC, r.name
172 LIMIT 1"#,
173 )
174 .bind(demand_location_id)
175 .bind(route_ids)
176 .fetch_optional(executor)
177 .await?;
178 Ok(row.map(rule_row))
179 }
180
181 pub async fn location_usage<'e, E>(
184 executor: E,
185 location_id: Uuid,
186 ) -> Result<Option<String>, sqlx::Error>
187 where
188 E: Executor<'e, Database = Postgres>,
189 {
190 let row = sqlx::query(
191 r#"SELECT usage::text AS usage FROM inventory.locations
192 WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
193 )
194 .bind(location_id)
195 .fetch_optional(executor)
196 .await?;
197 Ok(row.map(|r| r.get("usage")))
198 }
199
200 pub async fn insert_move<'e, E>(executor: E, m: &NewMoveRow) -> Result<Uuid, sqlx::Error>
203 where
204 E: Executor<'e, Database = Postgres>,
205 {
206 let row = sqlx::query(
207 r#"INSERT INTO inventory.stock_moves
208 (name, state, priority, item_id, demand_qty, quantity, price_unit,
209 procure_method, origin, location_id, location_dest_id,
210 rule_id, warehouse_id, orderpoint_id, move_orig_ids, move_dest_ids,
211 is_inventory, scrapped, propagate_cancel)
212 VALUES ($1, 'draft', 'normal', $2, $3, 0, 0,
213 $4::procure_method, $5, $6, $7,
214 $8, $9, $10, $11, $12, FALSE, FALSE, $13)
215 RETURNING id"#,
216 )
217 .bind(&m.name)
218 .bind(m.item_id)
219 .bind(m.demand_qty)
220 .bind(&m.procure_method)
221 .bind(&m.origin)
222 .bind(m.location_id)
223 .bind(m.location_dest_id)
224 .bind(m.rule_id)
225 .bind(m.warehouse_id)
226 .bind(m.orderpoint_id)
227 .bind(&m.move_orig_ids)
228 .bind(&m.move_dest_ids)
229 .bind(m.propagate_cancel)
230 .fetch_one(executor)
231 .await?;
232 Ok(row.get("id"))
233 }
234
235 pub async fn link_move_chain<'e, E>(
238 executor: E,
239 upstream_move_id: Uuid,
240 downstream_move_id: Uuid,
241 ) -> Result<(), sqlx::Error>
242 where
243 E: Executor<'e, Database = Postgres>,
244 {
245 sqlx::query(
246 r#"UPDATE inventory.stock_moves
247 SET move_dest_ids = array_append(move_dest_ids, $2)
248 WHERE id = $1"#,
249 )
250 .bind(upstream_move_id)
251 .bind(downstream_move_id)
252 .execute(executor)
253 .await?;
254 Ok(())
255 }
256
257 pub async fn claim_orderpoints<'e, E>(
262 executor: E,
263 limit: i64,
264 ) -> Result<Vec<OrderpointRow>, sqlx::Error>
265 where
266 E: Executor<'e, Database = Postgres>,
267 {
268 let rows = sqlx::query(
269 r#"SELECT id, name, trigger::text AS trigger, item_id, location_id, warehouse_id,
270 item_min_qty, item_max_qty, route_id, qty_to_order_manual
271 FROM inventory.reordering_rules op
272 WHERE op.active AND op.trigger = 'auto'
273 AND (op.snoozed_until IS NULL OR op.snoozed_until <= CURRENT_DATE)
274 AND (op.metadata->>'deleted_at') IS NULL
275 AND NOT EXISTS (
276 SELECT 1 FROM inventory.stock_moves m
277 WHERE m.orderpoint_id = op.id
278 AND m.state::text = ANY($1)
279 AND (m.metadata->>'deleted_at') IS NULL)
280 ORDER BY op.metadata->>'created_at', op.id
281 LIMIT $2
282 FOR UPDATE OF op SKIP LOCKED"#,
283 )
284 .bind(&["draft", "waiting", "confirmed", "partially_available", "assigned"][..])
285 .bind(limit)
286 .fetch_all(executor)
287 .await?;
288 Ok(rows
289 .into_iter()
290 .map(|r| OrderpointRow {
291 id: r.get("id"),
292 name: r.get("name"),
293 trigger: r.get("trigger"),
294 item_id: r.get("item_id"),
295 location_id: r.get("location_id"),
296 warehouse_id: r.get("warehouse_id"),
297 item_min_qty: r.get("item_min_qty"),
298 item_max_qty: r.get("item_max_qty"),
299 route_id: r.get("route_id"),
300 qty_to_order_manual: r.get("qty_to_order_manual"),
301 })
302 .collect())
303 }
304
305 pub async fn claim_assignable_moves<'e, E>(
308 executor: E,
309 limit: i64,
310 ) -> Result<Vec<ClaimedMoveRow>, sqlx::Error>
311 where
312 E: Executor<'e, Database = Postgres>,
313 {
314 let rows = sqlx::query(
315 r#"SELECT id, name, item_id, state::text AS state
316 FROM inventory.stock_moves
317 WHERE state::text = ANY($1)
318 AND (metadata->>'deleted_at') IS NULL
319 ORDER BY date, id
320 LIMIT $2
321 FOR UPDATE SKIP LOCKED"#,
322 )
323 .bind(&["confirmed", "partially_available"][..])
324 .bind(limit)
325 .fetch_all(executor)
326 .await?;
327 Ok(rows
328 .into_iter()
329 .map(|r| ClaimedMoveRow {
330 id: r.get("id"),
331 name: r.get("name"),
332 item_id: r.get("item_id"),
333 state: r.get("state"),
334 })
335 .collect())
336 }
337
338 pub async fn forecast_components<'e, E>(
341 executor: E,
342 item_id: Uuid,
343 location_id: Uuid,
344 ) -> Result<ForecastComponents, sqlx::Error>
345 where
346 E: Executor<'e, Database = Postgres>,
347 {
348 let row = sqlx::query(&format!(
349 r#"WITH demand AS (
350 SELECT parent_path FROM inventory.locations
351 WHERE id = $2 AND (metadata->>'deleted_at') IS NULL
352 )
353 SELECT
354 (SELECT COALESCE(SUM(quantity - reserved_quantity), 0)
355 FROM inventory.stock_quants q
356 WHERE q.item_id = $1
357 AND (q.metadata->>'deleted_at') IS NULL
358 AND q.location_id IN ({subtree})) AS on_hand,
359 (SELECT COALESCE(SUM(demand_qty - quantity), 0)
360 FROM inventory.stock_moves m
361 WHERE m.item_id = $1
362 AND m.state::text = ANY($3)
363 AND (m.metadata->>'deleted_at') IS NULL
364 AND m.location_dest_id IN ({subtree})) AS incoming,
365 (SELECT COALESCE(SUM(demand_qty - quantity), 0)
366 FROM inventory.stock_moves m
367 WHERE m.item_id = $1
368 AND m.state::text = ANY($3)
369 AND (m.metadata->>'deleted_at') IS NULL
370 AND m.location_id IN ({subtree})) AS outgoing"#,
371 subtree = SUBTREE_SQL
372 ))
373 .bind(item_id)
374 .bind(location_id)
375 .bind(&["waiting", "confirmed", "partially_available", "assigned"][..])
376 .fetch_one(executor)
377 .await?;
378 Ok(ForecastComponents {
379 on_hand: row.get("on_hand"),
380 incoming: row.get("incoming"),
381 outgoing: row.get("outgoing"),
382 })
383 }
384
385 pub async fn stamp_orderpoint<'e, E>(
387 executor: E,
388 orderpoint_id: Uuid,
389 c: &OrderpointComputes,
390 ) -> Result<(), sqlx::Error>
391 where
392 E: Executor<'e, Database = Postgres>,
393 {
394 sqlx::query(
395 r#"UPDATE inventory.reordering_rules
396 SET qty_on_hand = $2, qty_forecast = $3, qty_to_order = $4,
397 lead_days = $5, deadline_date = $6
398 WHERE id = $1"#,
399 )
400 .bind(orderpoint_id)
401 .bind(c.qty_on_hand)
402 .bind(c.qty_forecast)
403 .bind(c.qty_to_order)
404 .bind(c.lead_days)
405 .bind(c.deadline_date)
406 .execute(executor)
407 .await?;
408 Ok(())
409 }
410
411 pub async fn housekeep_quants<'e, E>(
415 executor: E,
416 limit: i64,
417 ) -> Result<u64, sqlx::Error>
418 where
419 E: Executor<'e, Database = Postgres>,
420 {
421 let res = sqlx::query(
422 r#"DELETE FROM inventory.stock_quants
423 WHERE ctid IN (
424 SELECT ctid FROM inventory.stock_quants
425 WHERE quantity = 0 AND reserved_quantity = 0
426 AND inventory_quantity_set = FALSE
427 AND (metadata->>'deleted_at') IS NULL
428 LIMIT $1)"#,
429 )
430 .bind(limit)
431 .execute(executor)
432 .await?;
433 Ok(res.rows_affected())
434 }
435
436 pub async fn rule_delay_days<'e, E>(
439 executor: E,
440 rule_id: Uuid,
441 ) -> Result<i32, sqlx::Error>
442 where
443 E: Executor<'e, Database = Postgres>,
444 {
445 let row = sqlx::query(
446 r#"SELECT delay FROM inventory.route_rules
447 WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
448 )
449 .bind(rule_id)
450 .fetch_optional(executor)
451 .await?;
452 Ok(row.map(|r| r.get::<i32, _>("delay")).unwrap_or(0))
453 }
454
455 pub async fn rule_picking_type<'e, E>(
459 executor: E,
460 rule_id: Uuid,
461 ) -> Result<Option<Uuid>, sqlx::Error>
462 where
463 E: Executor<'e, Database = Postgres>,
464 {
465 let row = sqlx::query_scalar::<_, Uuid>(
466 r#"SELECT picking_type_id FROM inventory.route_rules
467 WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
468 )
469 .bind(rule_id)
470 .fetch_optional(executor)
471 .await?;
472 Ok(row)
473 }
474
475 pub async fn insert_route<'e, E>(executor: E, id: Uuid, name: &str, active: bool, sequence: i32) -> Result<(), sqlx::Error>
477 where
478 E: Executor<'e, Database = Postgres>,
479 {
480 sqlx::query(
481 r#"INSERT INTO inventory.routes (id, name, active, sequence)
482 VALUES ($1, $2, $3, $4)"#,
483 )
484 .bind(id)
485 .bind(name)
486 .bind(active)
487 .bind(sequence)
488 .execute(executor)
489 .await?;
490 Ok(())
491 }
492
493 pub async fn insert_rule<'e, E>(
495 executor: E,
496 id: Uuid,
497 name: &str,
498 sequence: i32,
499 action: &str,
500 auto: &str,
501 procure_method: &str,
502 delay: i32,
503 location_src_id: Option<Uuid>,
504 location_dest_id: Uuid,
505 picking_type_id: Uuid,
506 route_id: Uuid,
507 warehouse_id: Option<Uuid>,
508 propagate_cancel: bool,
509 ) -> Result<(), sqlx::Error>
510 where
511 E: Executor<'e, Database = Postgres>,
512 {
513 sqlx::query(
514 r#"INSERT INTO inventory.route_rules
515 (id, name, active, sequence, action, auto, procure_method, delay,
516 location_src_id, location_dest_id, picking_type_id, route_id,
517 warehouse_id, propagate_cancel)
518 VALUES ($1, $2, TRUE, $3, $4::rule_action, $5::rule_auto,
519 $6::procure_method, $7, $8, $9, $10, $11, $12, $13)"#,
520 )
521 .bind(id)
522 .bind(name)
523 .bind(sequence)
524 .bind(action)
525 .bind(auto)
526 .bind(procure_method)
527 .bind(delay)
528 .bind(location_src_id)
529 .bind(location_dest_id)
530 .bind(picking_type_id)
531 .bind(route_id)
532 .bind(warehouse_id)
533 .bind(propagate_cancel)
534 .execute(executor)
535 .await?;
536 Ok(())
537 }
538
539 pub async fn insert_orderpoint<'e, E>(
541 executor: E,
542 id: Uuid,
543 name: &str,
544 trigger: &str,
545 item_id: Uuid,
546 location_id: Uuid,
547 warehouse_id: Uuid,
548 item_min_qty: Decimal,
549 item_max_qty: Decimal,
550 route_id: Option<Uuid>,
551 ) -> Result<(), sqlx::Error>
552 where
553 E: Executor<'e, Database = Postgres>,
554 {
555 sqlx::query(
556 r#"INSERT INTO inventory.reordering_rules
557 (id, name, trigger, active, item_id, location_id, warehouse_id,
558 item_min_qty, item_max_qty, route_id)
559 VALUES ($1, $2, $3::orderpoint_trigger, TRUE, $4, $5, $6, $7, $8, $9)"#,
560 )
561 .bind(id)
562 .bind(name)
563 .bind(trigger)
564 .bind(item_id)
565 .bind(location_id)
566 .bind(warehouse_id)
567 .bind(item_min_qty)
568 .bind(item_max_qty)
569 .bind(route_id)
570 .execute(executor)
571 .await?;
572 Ok(())
573 }
574
575 pub async fn orderpoint_exists<'e, E>(
577 executor: E,
578 item_id: Uuid,
579 location_id: Uuid,
580 ) -> Result<i64, sqlx::Error>
581 where
582 E: Executor<'e, Database = Postgres>,
583 {
584 sqlx::query_scalar::<_, i64>(
585 r#"SELECT COUNT(*) FROM inventory.reordering_rules
586 WHERE item_id = $1 AND location_id = $2
587 AND (metadata->>'deleted_at') IS NULL"#,
588 )
589 .bind(item_id)
590 .bind(location_id)
591 .fetch_one(executor)
592 .await
593 }
594}