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}