Skip to main content

backbone_inventory/application/service/
procurement_service.rs

1//! Procurement service (hand-authored, user-owned): `stock.route` / `stock.rule` /
2//! `stock.warehouse.orderpoint` — the pull/push procurement configuration and the reorder
3//! rung the daily scheduler drives (spec: docs/odoo/inventory/stock/stock-business-logic.md
4//! §6 routes/rules, §7 orderpoints + scheduler, §11 R6/R11, §12 T11).
5//!
6//! What lives here and what deliberately does not:
7//!
8//! - **Rule selection** (`search_rule`) — highest-sequence active pull-capable rule for a
9//!   demand location (§6). Feeds every move this service mints.
10//! - **`run_pull` / `run_push`** (Odoo `_run_pull` / `_run_push`) — mint DRAFT moves only.
11//!   Confirm/assign/done are the stock-move engine's verbs; the minted row enters the same
12//!   lifecycle every other move does. `run_push` is the entry the move engine calls on
13//!   `_action_confirm` to propagate a chain step (§1).
14//! - **Orderpoint computes** (T11) — on hand / forecast / to-order / lead days, written back
15//!   as stored computes on the orderpoint row. `qty_to_order = max(forecast, item_max_qty) -
16//!   forecast`, floored at zero; a positive `qty_to_order_manual` overrides the computed figure
17//!   for the order the scheduler mints.
18//! - **Rule destination (R13-rule)** — a rule must not route into a `view` location; the
19//!   service pre-checks it so a caller gets the typed error instead of a constraint 500.
20//! - **Orderpoint uniqueness (R6)** — the SERVICE half over the unique index
21//!   `(item_id, location_id)`; the index is the DB backstop.
22//!
23//! This file holds no SQL — every statement lives in
24//! `infrastructure/persistence/procurement_repository.rs` (the module's 4-layer rule).
25//!
26//! Tenancy (ADR-0029): the module is tenant-agnostic. Own transactions re-bind the caller's
27//! ambient org scope before writing (the composed decorator owns isolation); the module itself
28//! carries no tenant value.
29
30use async_trait::async_trait;
31use rust_decimal::Decimal;
32use sqlx::PgPool;
33use std::sync::Arc;
34use uuid::Uuid;
35
36use crate::domain::entity::MoveState;
37use crate::infrastructure::persistence::procurement_repository::{
38    ForecastComponents, NewMoveRow, OrderpointComputes, OrderpointRow, ProcurementRepository,
39    RuleRow,
40};
41
42use super::inventory_events::{InventoryEvent, InventoryEventSink, OrderpointTriggered};
43use super::inventory_write_service::legacy_company_echo;
44
45// --- errors -------------------------------------------------------------------
46
47#[derive(Debug)]
48pub enum ProcurementError {
49    /// R13-rule: a rule must not push into a `view` location.
50    RuleDestIsView { location_id: Uuid },
51    /// R6: an orderpoint already exists for this (item, location).
52    OrderpointExists { item_id: Uuid, location_id: Uuid },
53    /// No active pull rule matches the demand (route selection found nothing).
54    NoRuleForDemand { item_id: Uuid, location_id: Uuid },
55    /// The referenced row does not exist.
56    NotFound(Uuid),
57    Db(sqlx::Error),
58}
59
60impl ProcurementError {
61    pub fn code(&self) -> String {
62        // Codes are the stable error_codes declared in schema/hooks/stock.hook.yaml.
63        match self {
64            ProcurementError::RuleDestIsView { .. } => "rule_dest_is_view".into(),
65            ProcurementError::OrderpointExists { .. } => "orderpoint_exists".into(),
66            ProcurementError::NoRuleForDemand { .. } => "no_rule_for_demand".into(),
67            ProcurementError::NotFound(_) => "not_found".into(),
68            ProcurementError::Db(_) => "internal_error".into(),
69        }
70    }
71    pub fn http_status(&self) -> u16 {
72        match self {
73            ProcurementError::NotFound(_) => 404,
74            ProcurementError::Db(_) => 500,
75            _ => 422,
76        }
77    }
78}
79
80impl std::fmt::Display for ProcurementError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            ProcurementError::RuleDestIsView { .. } => write!(f, "rule_dest_is_view: a rule destination cannot be a view location (R13)"),
84            ProcurementError::OrderpointExists { item_id, location_id } => write!(f, "orderpoint_exists: a reordering rule already covers item {item_id} at location {location_id} (R6)"),
85            ProcurementError::NoRuleForDemand { item_id, location_id } => write!(f, "no_rule_for_demand: no active pull rule covers item {item_id} demanded at {location_id}"),
86            ProcurementError::NotFound(id) => write!(f, "not_found: {id}"),
87            ProcurementError::Db(e) => write!(f, "internal_error: {e}"),
88        }
89    }
90}
91impl std::error::Error for ProcurementError {}
92impl From<sqlx::Error> for ProcurementError {
93    fn from(e: sqlx::Error) -> Self {
94        ProcurementError::Db(e)
95    }
96}
97
98/// A move-lifecycle verb failure crossing the [`MovePipeline`] boundary. Deliberately a flat
99/// `{code, message}` — the implementing engine's own error taxonomy stays on its side of the
100/// port; the scheduler only logs and continues with the next claimed move.
101#[derive(Debug, Clone)]
102pub struct MovePipelineError {
103    pub code: String,
104    pub message: String,
105}
106
107impl std::fmt::Display for MovePipelineError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        write!(f, "{}: {}", self.code, self.message)
110    }
111}
112impl std::error::Error for MovePipelineError {}
113
114/// The stock-move engine's lifecycle verbs, seen from the scheduler (the port the engine
115/// implements — the same fail-closed shape as the module's other deferred ports: there is no
116/// silent no-op implementation, a job cannot run without a real engine behind it).
117///
118/// `confirm` is `_action_confirm` (draft → waiting/confirmed, which itself fires `run_push`
119/// for transparent push rules); `assign` is `_action_assign` (confirmed/partially_available →
120/// partially_available/assigned — the reservation apex write). Both receive the caller's
121/// connection: the scheduler drives them inside its per-batch transaction, so a batch's
122/// transitions commit or roll back together.
123#[async_trait]
124pub trait MovePipeline: Send + Sync {
125    async fn confirm(
126        &self,
127        conn: &mut sqlx::PgConnection,
128        move_id: Uuid,
129    ) -> Result<MoveState, MovePipelineError>;
130
131    async fn assign(
132        &self,
133        conn: &mut sqlx::PgConnection,
134        move_id: Uuid,
135    ) -> Result<MoveState, MovePipelineError>;
136}
137
138// --- inputs -------------------------------------------------------------------
139
140#[derive(Debug, Clone)]
141pub struct NewRoute {
142    pub name: String,
143    pub active: bool,
144    pub sequence: i32,
145}
146
147#[derive(Debug, Clone)]
148pub struct NewRouteRule {
149    pub name: String,
150    pub sequence: i32,
151    pub action: String,
152    pub auto: String,
153    pub procure_method: String,
154    pub delay: i32,
155    pub location_src_id: Option<Uuid>,
156    pub location_dest_id: Uuid,
157    pub picking_type_id: Uuid,
158    pub route_id: Uuid,
159    pub warehouse_id: Option<Uuid>,
160    pub propagate_cancel: bool,
161}
162
163#[derive(Debug, Clone)]
164pub struct NewOrderpoint {
165    pub name: String,
166    pub trigger: String,
167    pub item_id: Uuid,
168    pub location_id: Uuid,
169    pub warehouse_id: Uuid,
170    pub item_min_qty: Decimal,
171    pub item_max_qty: Decimal,
172    pub route_id: Option<Uuid>,
173}
174
175/// A demand that needs supply pulled to a location — the entry a confirm engine (sale-stock)
176/// or a buying bridge calls; implemented here, consumed by later passes.
177#[derive(Debug, Clone)]
178pub struct ProcurementRequest {
179    pub item_id: Uuid,
180    pub quantity: Decimal,
181    /// Where the stock is needed (the rule whose destination covers this location supplies it).
182    pub location_id: Uuid,
183    pub warehouse_id: Option<Uuid>,
184    /// Explicit route candidates; None = every active route.
185    pub route_ids: Option<Vec<Uuid>>,
186    pub origin: String,
187    /// The orderpoint this order answers (present when the scheduler minted it).
188    pub orderpoint_id: Option<Uuid>,
189}
190
191/// What one reorder rung produced — the durable record behind the `OrderpointTriggered` event.
192#[derive(Debug, Clone)]
193pub struct ReorderOutcome {
194    pub orderpoint_id: Uuid,
195    pub item_id: Uuid,
196    pub qty_to_order: Decimal,
197    pub forecast_qty: Decimal,
198    /// The minted replenishment move (draft until the pipeline confirms it).
199    pub move_id: Uuid,
200}
201
202/// Counters the scheduler reports per run.
203#[derive(Debug, Clone, Copy, Default)]
204pub struct AssignSweepReport {
205    pub claimed: usize,
206    pub assigned: usize,
207    pub partially_available: usize,
208    pub failed: usize,
209}
210
211// --- service ------------------------------------------------------------------
212
213#[derive(Clone)]
214pub struct ProcurementService {
215    db_pool: PgPool,
216    sink: Arc<dyn InventoryEventSink>,
217}
218
219impl ProcurementService {
220    pub fn new(db_pool: PgPool) -> Self {
221        Self::with_sink(db_pool, Arc::new(super::inventory_events::LoggingSink))
222    }
223
224    pub fn with_sink(db_pool: PgPool, sink: Arc<dyn InventoryEventSink>) -> Self {
225        Self { db_pool, sink }
226    }
227
228    /// The database this call runs on: the composer's request pool when one
229    /// is bound (a tenant mount, or a relay consumer wrapped by the host),
230    /// else the composed pool (ADR-0029 pool law).
231    fn rpool(&self) -> PgPool {
232        crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
233    }
234
235    // -- routes ---------------------------------------------------------------
236
237    /// Create a route (an ordered rule collection).
238    ///
239    /// The write rides its own transaction re-bound to the caller's ambient org scope
240    /// (ADR-0029) — the scope is task-local and a fresh pool transaction carries none of it;
241    /// the composed decorator's fill stamps the acting unit on the INSERT. Undecorated (module
242    /// tests, jobs) the transaction stays plain.
243    pub async fn create_route(&self, r: NewRoute) -> Result<Uuid, ProcurementError> {
244        let id = Uuid::new_v4();
245        let mut tx = self.rpool().begin().await?;
246        super::inventory_write_service::relay_ambient_scope(&mut tx).await?;
247        ProcurementRepository::insert_route(&mut *tx, id, &r.name, r.active, r.sequence).await?;
248        tx.commit().await?;
249        Ok(id)
250    }
251
252    // -- rules ----------------------------------------------------------------
253
254    /// Create a procurement rule after the R13-rule destination check (never route INTO a view
255    /// location). The DB trigger is the backstop for raw writers; this check exists so a
256    /// service caller gets the typed error instead of a constraint 500.
257    ///
258    /// The pre-check read and the insert ride one transaction re-bound to the caller's ambient
259    /// org scope (ADR-0029); undecorated (module tests, jobs) the transaction stays plain.
260    pub async fn create_rule(&self, r: NewRouteRule) -> Result<Uuid, ProcurementError> {
261        let mut tx = self.rpool().begin().await?;
262        super::inventory_write_service::relay_ambient_scope(&mut tx).await?;
263        self.check_rule_destination(&mut tx, r.location_dest_id).await?;
264
265        let id = Uuid::new_v4();
266        ProcurementRepository::insert_rule(
267            &mut *tx,
268            id,
269            &r.name,
270            r.sequence,
271            &r.action,
272            &r.auto,
273            &r.procure_method,
274            r.delay,
275            r.location_src_id,
276            r.location_dest_id,
277            r.picking_type_id,
278            r.route_id,
279            r.warehouse_id,
280            r.propagate_cancel,
281        )
282        .await?;
283        tx.commit().await?;
284        Ok(id)
285    }
286
287    /// The R13-rule service pre-check: never route INTO a view location. Runs on the caller's
288    /// connection so the read shares the caller's transaction.
289    async fn check_rule_destination(
290        &self,
291        conn: &mut sqlx::PgConnection,
292        location_dest_id: Uuid,
293    ) -> Result<(), ProcurementError> {
294        if let Some(usage) = ProcurementRepository::location_usage(&mut *conn, location_dest_id).await? {
295            if usage == "view" {
296                return Err(ProcurementError::RuleDestIsView { location_id: location_dest_id });
297            }
298        }
299        Ok(())
300    }
301
302    // -- rule selection + move minting (§6) ------------------------------------
303
304    /// `_search_rule`: the highest-sequence active pull-capable rule covering the demand
305    /// location. Public — the confirm engine and later bridges select rules through the same
306    /// ordering the scheduler does.
307    pub async fn search_rule(
308        &self,
309        demand_location_id: Uuid,
310        route_ids: Option<Vec<Uuid>>,
311    ) -> Result<Option<RuleRow>, ProcurementError> {
312        Ok(ProcurementRepository::search_rule(&self.rpool(), demand_location_id, route_ids).await?)
313    }
314
315    /// `_run_pull`: mint the inbound DRAFT move a selected pull rule prescribes — FROM the
316    /// rule's source (or the operation type's default source when the rule names none) TO the
317    /// rule's destination, at the requested quantity. The move enters the lifecycle as a draft;
318    /// the caller (scheduler or confirm engine) drives it through the pipeline.
319    pub async fn run_pull(
320        &self,
321        conn: &mut sqlx::PgConnection,
322        rule: &RuleRow,
323        req: &ProcurementRequest,
324    ) -> Result<Uuid, ProcurementError> {
325        let src = match rule.location_src_id {
326            Some(s) => s,
327            None => {
328                sqlx::query_scalar::<_, Uuid>(
329                    r#"SELECT default_location_src_id FROM inventory.operation_types
330                       WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
331                )
332                .bind(rule.picking_type_id)
333                .fetch_one(&mut *conn)
334                .await?
335            }
336        };
337        let row = NewMoveRow {
338            name: format!("PULL/{}/{}", rule.name, &Uuid::new_v4().simple().to_string()[..8]),
339            item_id: req.item_id,
340            demand_qty: req.quantity,
341            procure_method: rule.procure_method.clone(),
342            origin: req.origin.clone(),
343            location_id: src,
344            location_dest_id: rule.location_dest_id,
345            rule_id: Some(rule.id),
346            warehouse_id: rule.warehouse_id.or(req.warehouse_id),
347            orderpoint_id: req.orderpoint_id,
348            move_orig_ids: Vec::new(),
349            move_dest_ids: Vec::new(),
350            propagate_cancel: rule.propagate_cancel,
351        };
352        Ok(ProcurementRepository::insert_move(&mut *conn, &row).await?)
353    }
354
355    /// `_run_push`: propagate a confirmed move FROM the rule's source — mint the chained
356    /// downstream move (rule source → the upstream's destination) and link it onto the
357    /// upstream's `move_dest_ids`. Called by the move engine on `_action_confirm` for
358    /// `transparent` push rules; `manual` rules wait for a human.
359    pub async fn run_push(
360        &self,
361        conn: &mut sqlx::PgConnection,
362        rule: &RuleRow,
363        upstream_move: &crate::domain::entity::StockMove,
364    ) -> Result<Uuid, ProcurementError> {
365        let src = rule.location_src_id.unwrap_or(upstream_move.location_id);
366        let row = NewMoveRow {
367            name: format!("PUSH/{}/{}", rule.name, &Uuid::new_v4().simple().to_string()[..8]),
368            item_id: upstream_move.item_id,
369            demand_qty: upstream_move.demand_qty - upstream_move.quantity,
370            procure_method: rule.procure_method.clone(),
371            origin: upstream_move.name.clone(),
372            location_id: src,
373            location_dest_id: upstream_move.location_dest_id,
374            rule_id: Some(rule.id),
375            warehouse_id: rule.warehouse_id.or(upstream_move.warehouse_id),
376            orderpoint_id: upstream_move.orderpoint_id,
377            move_orig_ids: vec![upstream_move.id],
378            move_dest_ids: Vec::new(),
379            propagate_cancel: rule.propagate_cancel,
380        };
381        let downstream = ProcurementRepository::insert_move(&mut *conn, &row).await?;
382        ProcurementRepository::link_move_chain(&mut *conn, upstream_move.id, downstream).await?;
383        Ok(downstream)
384    }
385
386    /// The procurement-group entry point: resolve the rule for a demand and mint the pull
387    /// move. This is the surface a sale-stock confirm engine calls per storable line and a
388    /// purchase bridge (`_run_buy`) attaches to — implemented here, consumed by later passes.
389    pub async fn run_procurement(
390        &self,
391        conn: &mut sqlx::PgConnection,
392        req: &ProcurementRequest,
393    ) -> Result<Uuid, ProcurementError> {
394        let rule = self
395            .search_rule(req.location_id, req.route_ids.clone())
396            .await?
397            .ok_or(ProcurementError::NoRuleForDemand {
398                item_id: req.item_id,
399                location_id: req.location_id,
400            })?;
401        self.run_pull(conn, &rule, req).await
402    }
403
404    // -- orderpoints (§7, T11) --------------------------------------------------
405
406    /// Create an orderpoint (min/max replenishment rule) — the service half of R6: a duplicate
407    /// (item, location) coverage is the typed `orderpoint_exists` error; the partial unique
408    /// index is the DB backstop a raw writer hits instead.
409    ///
410    /// The duplicate-check read and the insert ride one transaction re-bound to the caller's
411    /// ambient org scope (ADR-0029); undecorated (module tests, jobs) it stays plain — the
412    /// composed decorator owns isolation.
413    pub async fn create_orderpoint(&self, o: NewOrderpoint) -> Result<Uuid, ProcurementError> {
414        let mut tx = self.rpool().begin().await?;
415        super::inventory_write_service::relay_ambient_scope(&mut tx).await?;
416        let existing = ProcurementRepository::orderpoint_exists(&mut *tx, o.item_id, o.location_id).await?;
417        if existing > 0 {
418            return Err(ProcurementError::OrderpointExists { item_id: o.item_id, location_id: o.location_id });
419        }
420
421        let id = Uuid::new_v4();
422        let inserted = ProcurementRepository::insert_orderpoint(
423            &mut *tx,
424            id,
425            &o.name,
426            &o.trigger,
427            o.item_id,
428            o.location_id,
429            o.warehouse_id,
430            o.item_min_qty,
431            o.item_max_qty,
432            o.route_id,
433        )
434        .await;
435        if let Err(e) = inserted {
436            if e.as_database_error().map(|d| d.is_unique_violation()).unwrap_or(false) {
437                return Err(ProcurementError::OrderpointExists {
438                    item_id: o.item_id,
439                    location_id: o.location_id,
440                });
441            }
442            return Err(e.into());
443        }
444        tx.commit().await?;
445        Ok(id)
446    }
447
448    /// The T11 computes for one orderpoint, from the forecast aggregation:
449    /// `qty_forecast = on_hand + incoming − outgoing` and
450    /// `qty_to_order = max(qty_forecast, item_max_qty) − qty_forecast` (floored at zero).
451    /// Lead days are the matched rule's delay; the deadline is `today + lead_days`.
452    pub async fn compute_orderpoint(
453        &self,
454        conn: &mut sqlx::PgConnection,
455        op: &OrderpointRow,
456    ) -> Result<OrderpointComputes, ProcurementError> {
457        let f: ForecastComponents =
458            ProcurementRepository::forecast_components(&mut *conn, op.item_id, op.location_id)
459                .await?;
460        let forecast = f.on_hand + f.incoming - f.outgoing;
461        let to_order = (op.item_max_qty - forecast).max(Decimal::ZERO);
462
463        let rule = ProcurementRepository::search_rule(
464            &mut *conn,
465            op.location_id,
466            op.route_id.map(|r| vec![r]),
467        )
468        .await?;
469        let delay = match &rule {
470            Some(r) => r.delay,
471            None => 0,
472        };
473        let lead_days = Decimal::from(delay);
474        let deadline = chrono::Utc::now().date_naive()
475            + chrono::Duration::days(i64::from(delay));
476        Ok(OrderpointComputes {
477            qty_on_hand: f.on_hand,
478            qty_forecast: forecast,
479            qty_to_order: to_order,
480            lead_days,
481            deadline_date: Some(deadline),
482        })
483    }
484
485    /// Recompute and persist one orderpoint's stored computes (the read-model refresh a
486    /// replenishment view drives for `manual` trigger rules — humans order, the computes
487    /// recommend).
488    ///
489    /// The orderpoint read rides the caller's ambient org scope (ADR-0029): under the composed
490    /// shape the decorator's fence bounds it and the forecast/stamp writes ride the same
491    /// bound transaction; undecorated (module tests, jobs) it is plain.
492    pub async fn recompute_orderpoint(
493        &self,
494        orderpoint_id: Uuid,
495    ) -> Result<OrderpointComputes, ProcurementError> {
496        let mut tx = self.rpool().begin().await?;
497        super::inventory_write_service::relay_ambient_scope(&mut tx).await?;
498        let op = sqlx::query_as::<_, OrderpointRowDb>(
499            r#"SELECT id, name, trigger::text AS trigger, item_id, location_id, warehouse_id,
500                      item_min_qty, item_max_qty, route_id, qty_to_order_manual
501               FROM inventory.reordering_rules
502               WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
503        )
504        .bind(orderpoint_id)
505        .fetch_optional(&mut *tx)
506        .await?
507        .ok_or(ProcurementError::NotFound(orderpoint_id))?;
508        let op = op.into_row();
509        let computes = self.compute_orderpoint(&mut tx, &op).await?;
510        ProcurementRepository::stamp_orderpoint(&mut *tx, orderpoint_id, &computes).await?;
511        tx.commit().await?;
512        Ok(computes)
513    }
514
515    /// The reorder rung (`_run_orderpoints`, §7 task 1) for ONE claimed orderpoint: refresh
516    /// the T11 computes, and when `qty_forecast < item_min_qty`, mint the replenishment move of
517    /// `qty_to_order` (a positive `qty_to_order_manual` overrides the computed figure) and
518    /// publish `OrderpointTriggered`. Returns the outcome when the rule fired.
519    ///
520    /// Runs inside the caller's transaction — the scheduler commits per batch and emits the
521    /// event after that commit (an event never precedes its durable record).
522    pub async fn reorder_one(
523        &self,
524        conn: &mut sqlx::PgConnection,
525        op: &OrderpointRow,
526    ) -> Result<Option<ReorderOutcome>, ProcurementError> {
527        let computes = self.compute_orderpoint(&mut *conn, op).await?;
528        ProcurementRepository::stamp_orderpoint(&mut *conn, op.id, &computes).await?;
529        if computes.qty_forecast >= op.item_min_qty {
530            return Ok(None);
531        }
532        let rule = ProcurementRepository::search_rule(
533            &mut *conn,
534            op.location_id,
535            op.route_id.map(|r| vec![r]),
536        )
537        .await?
538        .ok_or(ProcurementError::NoRuleForDemand {
539            item_id: op.item_id,
540            location_id: op.location_id,
541        })?;
542        let qty = if op.qty_to_order_manual > Decimal::ZERO {
543            op.qty_to_order_manual
544        } else {
545            computes.qty_to_order
546        };
547        let req = ProcurementRequest {
548            item_id: op.item_id,
549            quantity: qty,
550            location_id: op.location_id,
551            warehouse_id: Some(op.warehouse_id),
552            route_ids: op.route_id.map(|r| vec![r]),
553            origin: format!("orderpoint:{}", op.name),
554            orderpoint_id: Some(op.id),
555        };
556        let move_id = self.run_pull(&mut *conn, &rule, &req).await?;
557        Ok(Some(ReorderOutcome {
558            orderpoint_id: op.id,
559            item_id: op.item_id,
560            qty_to_order: qty,
561            forecast_qty: computes.qty_forecast,
562            move_id,
563        }))
564    }
565
566    /// Publish the reorder event (after the batch commit). Kept separate from
567    /// [`Self::reorder_one`] so the transaction boundary stays explicit.
568    pub fn emit_orderpoint_triggered(&self, o: &ReorderOutcome) {
569        self.sink.publish(InventoryEvent::OrderpointTriggered(OrderpointTriggered {
570            orderpoint_id: o.orderpoint_id,
571            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
572            // consumers that still read a tenant off the wire. No module statement keys on it.
573            company_id: legacy_company_echo(),
574            item_id: o.item_id,
575            qty_to_order: o.qty_to_order,
576            forecast_qty: o.forecast_qty,
577        }));
578    }
579
580    // -- assign sweep (§7 task 2) ----------------------------------------------
581
582    /// `_run_scheduler_assign` for ONE claimed move: hand it to the move engine's assign verb
583    /// on the caller's connection. The sweep (claiming, ordering, accounting) lives in the
584    /// scheduler job; this is the per-move step so the engine contract is exercisable in
585    /// isolation.
586    pub async fn assign_one(
587        &self,
588        conn: &mut sqlx::PgConnection,
589        pipeline: &dyn MovePipeline,
590        move_id: Uuid,
591    ) -> Result<MoveState, MovePipelineError> {
592        pipeline.assign(conn, move_id).await
593    }
594
595    /// The quant vacuum tail (§7 task 3) — see the repository for the R21-shaped predicate.
596    /// Returns the raw driver error: this is a pure delegation with no domain decision, and
597    /// the scheduler (its only caller) reports infrastructure failures as-is.
598    pub async fn housekeep_quants(
599        &self,
600        conn: &mut sqlx::PgConnection,
601        limit: i64,
602    ) -> Result<u64, sqlx::Error> {
603        ProcurementRepository::housekeep_quants(conn, limit).await
604    }
605
606    pub fn sink(&self) -> Arc<dyn InventoryEventSink> {
607        self.sink.clone()
608    }
609}
610
611/// Query-shaped orderpoint row (private) — maps straight onto [`OrderpointRow`].
612#[derive(sqlx::FromRow)]
613struct OrderpointRowDb {
614    id: Uuid,
615    name: String,
616    r#trigger: String,
617    item_id: Uuid,
618    location_id: Uuid,
619    warehouse_id: Uuid,
620    item_min_qty: Decimal,
621    item_max_qty: Decimal,
622    route_id: Option<Uuid>,
623    qty_to_order_manual: Decimal,
624}
625
626impl OrderpointRowDb {
627    fn into_row(self) -> OrderpointRow {
628        OrderpointRow {
629            id: self.id,
630            name: self.name,
631            trigger: self.r#trigger,
632            item_id: self.item_id,
633            location_id: self.location_id,
634            warehouse_id: self.warehouse_id,
635            item_min_qty: self.item_min_qty,
636            item_max_qty: self.item_max_qty,
637            route_id: self.route_id,
638            qty_to_order_manual: self.qty_to_order_manual,
639        }
640    }
641}