Skip to main content

backbone_inventory/infrastructure/persistence/
procurement_repository.rs

1//! Procurement SQL (hand-authored, user-owned): everything behind the routes/rules/orderpoints
2//! service (`application/service/procurement_service.rs`) and the daily scheduler
3//! (`infrastructure/jobs/run_scheduler.rs`).
4//!
5//! Scope: rule search (`_search_rule`, stock-business-logic §6), the orderpoint T11 forecast
6//! aggregation (§12 T11), replenishment-move minting, the scheduler's intake claims
7//! (`FOR UPDATE SKIP LOCKED`, ADR-0020 §4), and the quant vacuum tail. The move lifecycle verbs
8//! themselves (confirm / assign / done) live in the stock-move engine — this file only mints
9//! draft rows and claims existing ones; it never mutates a move's state.
10//!
11//! Conventions carried from the module's proven hand-written SQL:
12//! - enum values bind as text with an explicit cast (`$n::move_state`), never via `sqlx::Type`
13//!   derives, and decode through `::text` aliases (the schema-normalization R1 lessons);
14//! - every read filters `(metadata->>'deleted_at') IS NULL` (soft-delete grain);
15//! - tenancy is composition-installed (ADR-0029): statements are grain-keyed only — the
16//!   composing service's org fence bounds what a claim or search can see.
17
18use rust_decimal::Decimal;
19use sqlx::{Executor, Postgres, Row};
20use uuid::Uuid;
21
22/// A `stock.rule` row as the procurement surface reads it (`inventory.route_rules`).
23#[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/// A `stock.warehouse.orderpoint` row as the scheduler claims it (`inventory.reordering_rules`).
41#[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/// A claimed confirmed/partially-available move (the scheduler's assign sweep intake).
56#[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/// The T11 inputs gathered per orderpoint: available on hand plus the open incoming/outgoing
65/// remainder around the monitored location subtree.
66#[derive(Debug, Clone, Copy, Default)]
67pub struct ForecastComponents {
68    pub on_hand: Decimal,
69    pub incoming: Decimal,
70    pub outgoing: Decimal,
71}
72
73/// The stored computes written back onto the orderpoint row (§12 T11).
74#[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/// The values a minted draft move carries into `inventory.stock_moves`.
84#[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
119/// SQL fragment shared by every subtree read: the set of location ids at or below the demand
120/// location's path (a CTE named `demand` exposing `parent_path`). A location's `parent_path`
121/// prefixes every descendant's path, so `LIKE path || '%'` is the whole subtree including the
122/// location itself.
123const SUBTREE_SQL: &str =
124    "SELECT l.id FROM inventory.locations l, demand d WHERE l.parent_path LIKE d.parent_path || '%'";
125
126/// Unit struct over static SQL — every method is stateless and takes its executor from the
127/// caller (the scheduler passes its batch transaction; service entry points pass the pool).
128#[derive(Debug, Clone, Copy, Default)]
129pub struct ProcurementRepository;
130
131impl ProcurementRepository {
132    pub fn new() -> Self {
133        Self
134    }
135
136    /// `_search_rule` (§6): the highest-sequence ACTIVE pull-capable rule whose destination
137    /// location COVERS the demand — the demand sits at or below the destination on the location
138    /// tree (`demand.parent_path LIKE dest.parent_path || '%'`), so a rule pulling into an
139    /// ancestor (WH/Stock) serves a demand in a child bin (WH/Stock/Shelf-1). The join enforces
140    /// the coverage; the route must be active. Which rules the caller may see is the composing
141    /// service's org fence's business (shared masters ride the root-shared union).
142    ///
143    /// `route_ids: None` means "search every active route" (the warehouse/orderpoint-resolved
144    /// default); `Some(ids)` restricts to explicit candidates (e.g. the orderpoint's
145    /// `route_id` override).
146    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    /// The usage of a location (`location_usage` as text) — the R13-rule pre-check reads it
182    /// (a rule never pushes into a view location).
183    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    /// Mint a DRAFT move (`_run_pull` / `_run_push` output). State transitions are the move
201    /// engine's alone — this only inserts.
202    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    /// Link a push-minted downstream move onto its upstream's `move_dest_ids` (the chain the
236    /// waiting/done propagation walks).
237    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    /// Claim auto-trigger orderpoints for the reorder task (ADR-0020 §4): `FOR UPDATE SKIP
258    /// LOCKED`, and — critically for the `commit_per_batch` replay window — an orderpoint that
259    /// already has an open replenishment move is excluded, so a crash mid-run re-orders at most
260    /// the batch that never committed, never a second move for the same orderpoint.
261    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    /// Claim the assign sweep's intake: confirmed and partially-available moves, `FOR UPDATE
306    /// SKIP LOCKED` (two concurrent scheduler replicas take disjoint sets).
307    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    /// The T11 aggregation inputs around the orderpoint's location subtree:
339    /// available on hand (quants), open incoming remainder, open outgoing remainder.
340    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    /// Write the T11 computes back onto the orderpoint row.
386    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    /// The quant vacuum tail (§7 task 3): remove zero-quantity, fully-unreserved quants with no
412    /// staged count. R21 (a quant unlinks only at zero) is the predicate itself; a staged count
413    /// (`inventory_quantity_set`) pins its row until the count applies.
414    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    /// The lead-days half of T11: the matched rule's delay in days (the supplier-info leg is a
437    /// later increment; when no rule matches, lead days are zero).
438    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    /// The operation type a rule fulfills through (its `picking_type_id`) — the vocabulary the
456    /// picking-assignment step derives a rule-launched move's grouping transfer from. `None`
457    /// when the rule does not exist or is soft-deleted.
458    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    /// Insert a new route (procurement configuration).
476    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    /// Insert a new route rule after service-layer pre-checks.
494    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    /// Insert a new orderpoint (reordering rule) after service-layer duplicate check.
540    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    /// Check for an existing orderpoint covering (item, location).
576    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}