Skip to main content

backbone_inventory/application/service/
inventory_scrap.rs

1//! The scrap door (hand-authored, user-owned) — the stock.scrap satellite (spec
2//! stock-business-logic.md §8).
3//!
4//! A scrap is an OPERATOR DOCUMENT with a genuine hand-set state (`draft` → `done` —
5//! unlike the picking-batch projection), but the physical/valuation work rides the ONE
6//! stock-move engine: `process_scrap` mints a single move (`scrapped = true`,
7//! `is_inventory = true`) from the stock location to the scrap location, drives it
8//! confirm → assign → done through the engine verbs, and stamps the header `done` with
9//! the move id. No second estate: the quants, the SLE pair, the Bin reblende, and the GL
10//! leg are all the engine's, exactly as for every other door.
11//!
12//! The scrap location defaults to the inventory-loss location (the same sink the
13//! reconciliation door moves through), overridable per scrap. The GL shape is the
14//! adjustment shape (the engine's `is_inventory` leg): the directive's
15//! `adjustment_account_id` carries the scrap expense account, `inventory_account_id` the
16//! stock account. The HTTP surface gets the DEFERRED form (the module's standing posture:
17//! the composing service's sink is not available on a bare route); a service-driven
18//! process posts through its sink, and `repost_move_gl` re-drives a stuck leg.
19//!
20//! Tenancy (ADR-0029): the module is tenant-agnostic. Every write transaction this file
21//! opens re-binds the caller's ambient org scope (`relay_ambient_scope`); the composing
22//! decorator owns isolation.
23//!
24//! Per the module's 4-layer rule this file holds no SQL — statements live on
25//! [`super::super::super::infrastructure::persistence::ScrapDoorRepository`].
26
27use rust_decimal::Decimal;
28use uuid::Uuid;
29
30use crate::infrastructure::persistence::{NewScrapRow, ScrapRow};
31
32use super::inventory_gl::GlPostSink;
33use super::inventory_move_engine::{BackorderPolicy, MoveGlDirective, NewStockMove};
34use super::inventory_write_service::{
35    is_dup, relay_ambient_scope, InventoryError, InventoryWriteService,
36};
37
38/// The scrap-mint input. `scrap_location_id` defaults to the inventory-loss
39/// location when `None`. Guards on mint: strictly positive quantity (R-shaped), a
40/// stockable internal source location (R13).
41#[derive(Debug, Clone)]
42pub struct NewScrap {
43    pub item_id: Uuid,
44    pub scrap_qty: Decimal,
45    /// Where the scrapped stock currently sits (internal usage).
46    pub location_id: Uuid,
47    /// Where the scrap lands (the loss sink); `None` resolves the default loss sink.
48    pub scrap_location_id: Option<Uuid>,
49    pub lot_id: Option<Uuid>,
50    pub package_id: Option<Uuid>,
51    pub owner_id: Option<Uuid>,
52    pub picking_id: Option<Uuid>,
53    pub origin: Option<String>,
54    pub scrap_reason_tag_ids: Vec<Uuid>,
55}
56
57/// The scrap process outcome: the closed header and the engine move it landed through.
58#[derive(Debug, Clone)]
59pub struct ScrapProcessed {
60    pub scrap_id: Uuid,
61    pub move_id: Uuid,
62}
63
64impl InventoryWriteService {
65    // ---- scrap: mint / process / probe ---------------------------------------------
66
67    /// Mint a scrap order in `draft`. Nothing moves yet — the header records WHAT will be
68    /// scrapped, where from, and where to. Guards: strictly positive quantity, a live
69    /// internal source location, a resolvable scrap location. The name is
70    /// minted `SCRAP/<short-uuid>` (org-scoped unique — the typed duplicate error on the
71    /// rare collision).
72    pub async fn create_scrap(&self, s: NewScrap) -> Result<ScrapRow, InventoryError> {
73        if s.scrap_qty <= Decimal::ZERO {
74            return Err(InventoryError::NegativeQuantity);
75        }
76        let id = Uuid::new_v4();
77        let name = format!("SCRAP/{}", &Uuid::new_v4().simple().to_string()[..8].to_uppercase());
78        let mut tx = self.rpool().begin().await?;
79        // Re-bind the caller's ambient org scope before any read (ADR-0029) — the scope is
80        // task-local and a fresh pool transaction carries none of it; undecorated (module
81        // tests, jobs) the transaction stays plain.
82        relay_ambient_scope(&mut tx).await?;
83        // R13-shaped pre-check: the source must be a stockable INTERNAL location (a view
84        // location holds no stock).
85        let locs = self.moves.fetch_move_locations(&mut tx, s.location_id, s.location_id).await?;
86        let src = locs.0.ok_or(InventoryError::LocationNotFound(s.location_id))?;
87        if src.usage != "internal" {
88            return Err(InventoryError::ViewLocationHoldsNoStock { location_id: s.location_id });
89        }
90        // The scrap sink: explicit when given, else the inventory-loss location
91        // (bootstrapped on first use — the same sink the reconciliation door uses).
92        let scrap_location_id = match s.scrap_location_id {
93            Some(explicit) => {
94                let dst = self.moves.fetch_move_locations(&mut tx, explicit, explicit).await?;
95                match dst.1.or(dst.0) {
96                    Some(facts) if facts.usage == "inventory" => explicit,
97                    _ => return Err(InventoryError::ScrapLocationUnavailable),
98                }
99            }
100            None => self.pickings.ensure_inventory_loss_location(&mut tx).await
101                .map_err(|_| InventoryError::ScrapLocationUnavailable)?,
102        };
103        let ins = self.scraps.insert_scrap(&mut tx, &NewScrapRow {
104            id,
105            name: &name,
106            origin: s.origin.as_deref(),
107            item_id: s.item_id,
108            scrap_qty: s.scrap_qty,
109            location_id: s.location_id,
110            scrap_location_id,
111            lot_id: s.lot_id,
112            package_id: s.package_id,
113            owner_id: s.owner_id,
114            picking_id: s.picking_id,
115            scrap_reason_tag_ids: s.scrap_reason_tag_ids,
116        }).await;
117        if let Err(e) = ins {
118            return Err(if is_dup(&e) { InventoryError::DuplicateNumber(name) } else { e.into() });
119        }
120        tx.commit().await?;
121        self.fetch_scrap(id).await?
122            .ok_or(InventoryError::NotFound(id))
123    }
124
125    /// The probe: read one scrap header, riding the caller's ambient org scope (ADR-0029).
126    pub async fn fetch_scrap(&self, scrap_id: Uuid) -> Result<Option<ScrapRow>, InventoryError> {
127        let mut tx = self.rpool().begin().await?;
128        relay_ambient_scope(&mut tx).await?;
129        let row = self.scraps.fetch_scrap(&mut tx, scrap_id).await?;
130        tx.commit().await?;
131        Ok(row)
132    }
133
134    /// Process a DRAFT scrap: mint the move, drive it to done through the engine, stamp
135    /// the header. The full-GL form — the caller's directive carries the accounts and the
136    /// sink posts the envelope. The move is `is_inventory` (the engine's adjustment leg
137    /// shape: the directive's `adjustment_account_id` is the scrap expense account) and
138    /// `scrapped` (the engine's marker for scrap-marked moves). A scrap lands WHOLE: the
139    /// backorder policy is `Never` and a move that cannot fully reserve refuses loudly
140    /// (you cannot scrap stock that is not there — R22).
141    ///
142    /// Crash-window safe: the done-stamp's `state = 'draft'` guard means exactly one
143    /// process call closes the header; a crash after the move landed but before the stamp
144    /// leaves the scrap re-processable, and the resume check below re-drives the SAME
145    /// move instead of minting a second one.
146    pub async fn process_scrap(
147        &self,
148        scrap_id: Uuid,
149        gl: &MoveGlDirective,
150        sink: &dyn GlPostSink,
151    ) -> Result<ScrapProcessed, InventoryError> {
152        let scrap = self.fetch_scrap(scrap_id).await?
153            .ok_or(InventoryError::NotFound(scrap_id))?;
154        match scrap.state.as_str() {
155            "draft" => {}
156            other => return Err(InventoryError::ScrapNotDraft { scrap_id, state: other.into() }),
157        }
158        let move_id = match scrap.move_id {
159            // Resume: a prior process minted the move but did not close the header (crash
160            // between done and stamp). Re-driving the same move keeps the estate single.
161            Some(prior) => prior,
162            None => self.create_move(NewStockMove {
163                name: scrap.name.clone(),
164                item_id: scrap.item_id,
165                demand_qty: scrap.scrap_qty,
166                price_unit: Decimal::ZERO, // the loss is valued at the current average
167                procure_method: "make_to_stock".into(),
168                picking_id: None,
169                origin: scrap.origin.clone(),
170                location_id: scrap.location_id,
171                location_dest_id: scrap.scrap_location_id,
172                partner_id: None,
173                warehouse_id: None,
174                orderpoint_id: None,
175                move_orig_ids: vec![],
176                move_dest_ids: vec![],
177                is_inventory: true,
178                scrapped: true,
179                forced_value: None,
180            }).await?,
181        };
182        // Drive the pipeline, re-reading the state between verbs (the engine owns the
183        // state; this method never asserts it).
184        let mv = {
185            let mut tx = self.rpool().begin().await?;
186            relay_ambient_scope(&mut tx).await?;
187            let mv = self.moves.fetch_move(&mut tx, move_id).await?
188                .ok_or(InventoryError::NotFound(move_id))?;
189            tx.commit().await?;
190            mv
191        };
192        let mut state = mv.state.clone();
193        if state == "draft" {
194            state = self.action_confirm(move_id).await?;
195        }
196        if matches!(state.as_str(), "confirmed" | "partially_available") {
197            self.action_assign(move_id).await?;
198            state = self.move_state_of(move_id).await?;
199        }
200        if state == "assigned" || state == "waiting" {
201            // The waiting case is the resume window's residue (a move confirmed against
202            // nothing reservable); the assign pass above is what freed or refused it.
203            let fresh = self.move_state_of(move_id).await?;
204            if fresh == "waiting" {
205                return Err(InventoryError::WrongMoveState {
206                    move_id, action: "process_scrap", current: fresh,
207                });
208            }
209            state = fresh;
210        }
211        if state != "assigned" {
212            return Err(InventoryError::WrongMoveState {
213                move_id, action: "process_scrap", current: state,
214            });
215        }
216        let mv = {
217            let mut tx = self.rpool().begin().await?;
218            relay_ambient_scope(&mut tx).await?;
219            let mv = self.moves.fetch_move(&mut tx, move_id).await?
220                .ok_or(InventoryError::NotFound(move_id))?;
221            tx.commit().await?;
222            mv
223        };
224        self.prepare_move_for_validate(&mv).await?;
225        self.action_done(move_id, BackorderPolicy::Never, gl, sink).await?;
226        {
227            let mut tx = self.rpool().begin().await?;
228            relay_ambient_scope(&mut tx).await?;
229            let closed = self.scraps.mark_scrap_done(&mut tx, scrap_id, move_id).await?;
230            tx.commit().await?;
231            if !closed {
232                return Err(InventoryError::ScrapNotDraft { scrap_id, state: "done".into() });
233            }
234        }
235        Ok(ScrapProcessed { scrap_id, move_id })
236    }
237
238    /// The HTTP-shaped process: the SAME physical movement (the move lands done, the
239    /// quants flip, the SLEs mint), but the GL leg carries a no-accounts directive — the
240    /// engine builds no envelope and the move's posting stays `not_applicable` until a
241    /// service-driven [`Self::repost_move_gl`] drives it with real accounts (the module's
242    /// standing deferred posture for GL-posting verbs on a bare route).
243    pub async fn process_scrap_deferred(
244        &self,
245        scrap_id: Uuid,
246    ) -> Result<ScrapProcessed, InventoryError> {
247        let no_accounts = MoveGlDirective {
248            cogs_account_id: None,
249            inventory_account_id: None,
250            grir_account_id: None,
251            adjustment_account_id: None,
252            currency: "IDR".into(),
253        };
254        struct NoGlSink;
255        #[async_trait::async_trait]
256        impl GlPostSink for NoGlSink {
257            async fn post(
258                &self,
259                _e: &super::inventory_gl::AccountingPostEnvelope,
260            ) -> Result<super::inventory_gl::GlPostAck, super::inventory_gl::GlPostRejected> {
261                Err(super::inventory_gl::GlPostRejected {
262                    code: "no_gl_on_scrap_deferred".into(),
263                    message: "the deferred scrap process posts no GL; drive repost_move_gl".into(),
264                })
265            }
266        }
267        self.process_scrap(scrap_id, &no_accounts, &NoGlSink).await
268    }
269}