backbone_inventory/application/service/inventory_move_engine.rs
1//! The converged stock-move engine (hand-authored, user-owned): `_action_confirm` /
2//! `_action_assign` / `_action_done` / `_action_cancel` over quant-grain reservation.
3//!
4//! Spec: `docs/odoo/inventory/stock/stock-business-logic.md` §1 (7-state lifecycle), §2 (T1
5//! picking projection), §3 (reservation triangle), §4 (`_action_done` pipeline); the declared
6//! contract is `schema/hooks/stock.hook.yaml`. The MOVE owns the lifecycle
7//! (draft/waiting/confirmed/partially_available/assigned/done/cancel); the transfer/picking state
8//! is a PROJECTION re-derived on every move change (never its own state machine, ADR-0016).
9//!
10//! **The reservation triangle** (§3): `stock_quants.reserved_quantity` is AUTHORITATIVE — written
11//! only under the quant row's `FOR UPDATE` (`QuantRepository`); each move LINE mirrors the grain it
12//! reserved (item x src x dest x lot x package x owner x qty); the move STATE aggregates the mirror
13//! (`assigned` when the mirror meets demand, `partially_available` when positive but short).
14//! `available = quantity - reserved` is a READ everywhere (T2), never a second writer.
15//!
16//! **`_action_done`** (§4): guards R22 (physical stock suffices for every draw) + R23 (positive
17//! qty) + R24-at-move-grain (done needs lines) + R9 (src != dest); then per line the TWO-STEP
18//! `_synchronize_quant` — the reserved step releases the line's reservation, the available step
19//! moves the physical quantity (src quant decremented, dest quant incremented); done-qty propagates
20//! along `move_orig_ids`/`move_dest_ids` (a `waiting` child releases to `confirmed` once ALL its
21//! parents are done); a partial done (qty < demand) mints the BACKORDER move per policy; `date` is
22//! stamped to the processing instant and `state='done'`; the transfer reprojects (T1).
23//!
24//! **Valuation (money path — unchanged contract).** The pipeline mints Stock Ledger Entries through
25//! the EXISTING `StockLedgerEntryRepository` + the Bin moving-average reblende, and posts the GL
26//! legs through the existing `AccountingPost` seam (`inventory_gl.rs`). There is NO
27//! stock.valuation.layer and no second ledger: receipt blends the rate (`value += qty·price; rate =
28//! value/qty`), delivery consumes the current average (`cogs = qty·rate; rate unchanged`),
29//! transfer is value-neutral at the carried rate — the W1-proven legs. The V7 ordering invariant
30//! (stock-account §2.1: OUT valued BEFORE, IN valued AFTER) is structural here: the OUT leg's value
31//! is computed from the src Bin snapshot taken under the canonical FOR UPDATE locks BEFORE the dest
32//! reblend runs; the IN leg then reblends the destination with the carried value.
33//!
34//! **Voucher identity note.** Move-minted SLE rows ride `voucher_type='stock_entry'` (the generic
35//! stock-operation door) with `voucher_id = move_id` — the `voucher_type` enum has no
36//! `stock_move` variant, and none is needed: consumers discriminate by the id columns, not the
37//! type string. GL legs are emitted with `idempotency_key = move_id` under
38//! `source_type='inventory'`, `source_id = move_id`, `posting_type='original'` — the same
39//! producer name the voucher doors use, disambiguated by the source id (each post's source is
40//! exactly one document: a voucher OR a move, never both). The move's GL settlement is recorded
41//! on the move itself: `_action_done` arms `posting_state='pending'` in the movement's own
42//! transaction when it built an envelope, and reconciles to `posted`/`failed` once the sink
43//! answers. A rejected post does NOT roll the physical movement back — the move parks in
44//! `failed` (a durable, sweepable record of the missing GL leg) and `repost_move_gl` re-drives
45//! it; accounting's dedupe on `(company, source_type, source_id, posting_type)` makes the
46//! re-drive idempotent. Moves that post no GL of their own (voucher-door legs whose GL is owned
47//! by the voucher, value-neutral warehouse-to-warehouse shapes, directives without the needed
48//! accounts) stay `not_applicable`.
49//!
50//! Per the module's 4-layer rule this file holds no SQL — the statements live on
51//! `QuantRepository` / `StockMoveRepository` / `StockMoveLineRepository` / `BinRepository` /
52//! `StockLedgerEntryRepository`, whose write methods take THIS service's transaction so the quant
53//! flips, the Bin reblende, the SLE rows and the move state commit as one unit.
54
55use async_trait::async_trait;
56use chrono::Utc;
57use rust_decimal::Decimal;
58use uuid::Uuid;
59
60use crate::domain::entity::MoveState;
61use crate::infrastructure::persistence::{MoveRow, NewMoveLineRow, NewMoveRow, QuantDims};
62
63use super::inventory_events::{
64 BackorderCreated, InventoryEvent, MoveAssigned, MoveCancelled, MoveConfirmed, MoveDone,
65 TransferProjected,
66};
67use super::inventory_gl::{AccountingPostEnvelope, GlPostLine, GlPostSink};
68use super::inventory_posture::Posture;
69use super::inventory_write_service::{
70 legacy_company_echo, money, rate6, relay_ambient_scope, InventoryError, InventoryWriteService,
71 SubmitOutcome,
72};
73use super::procurement_service::{MovePipeline, MovePipelineError};
74
75// --- input vocabulary ---------------------------------------------------------
76
77/// The move-creation input (draft insert; the pipeline advances state).
78#[derive(Debug, Clone)]
79pub struct NewStockMove {
80 pub name: String,
81 pub item_id: Uuid,
82 pub demand_qty: Decimal,
83 /// Unit valuation price the SLE mint uses on the IN leg (external receipts). 0 = carry the
84 /// current average (value-neutral receive).
85 pub price_unit: Decimal,
86 /// "make_to_stock" | "make_to_order" | "mts_else_mto".
87 pub procure_method: String,
88 pub picking_id: Option<Uuid>,
89 pub origin: Option<String>,
90 pub location_id: Uuid,
91 pub location_dest_id: Uuid,
92 pub partner_id: Option<Uuid>,
93 pub warehouse_id: Option<Uuid>,
94 pub orderpoint_id: Option<Uuid>,
95 pub move_orig_ids: Vec<Uuid>,
96 pub move_dest_ids: Vec<Uuid>,
97 pub is_inventory: bool,
98 pub scrapped: bool,
99 /// Explicit total value the move carries — a document reversal valued at its original
100 /// amount. When set, the valuation core uses it on the value-bearing leg(s) instead of the
101 /// average/price-derived carry, so reversing a voucher restores the estate to exactly its
102 /// pre-movement state. `None` on ordinary moves.
103 pub forced_value: Option<Decimal>,
104}
105
106/// Backorder policy on partial validate (spec §4 step 4: `create_backorder ∈ {always, never,
107/// delayed}` — the operation type's vocabulary). `Always` mints the backorder CONFIRMED and
108/// reserves it right away (reserve on mint); `Delayed` mints it CONFIRMED but defers the
109/// reservation to the scheduler's assign sweep; `Never` leaves the remainder unbackordered.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum BackorderPolicy {
112 Always,
113 Never,
114 Delayed,
115}
116
117/// GL accounts for the legs `_action_done` may post. Accounts are inventory/composition config —
118/// the same posture as the intake contract (the caller supplies them; they are not move columns).
119/// A leg whose accounts are absent is simply not posted (`gl_posted=false`), the physical movement
120/// is unaffected.
121#[derive(Debug, Clone, Default)]
122pub struct MoveGlDirective {
123 /// COGS debit account for OUT legs (internal -> customer).
124 pub cogs_account_id: Option<Uuid>,
125 /// Inventory valuation account (credit side of OUT, debit side of IN).
126 pub inventory_account_id: Option<Uuid>,
127 /// GR/IR clearing account (credit side of external IN legs).
128 pub grir_account_id: Option<Uuid>,
129 /// Adjustment account for `is_inventory` moves (the value-diff counterleg).
130 pub adjustment_account_id: Option<Uuid>,
131 /// Ledger currency of the post.
132 pub currency: String,
133}
134
135/// Outcome of `_action_done`.
136#[derive(Debug, Clone)]
137pub struct MoveDoneOutcome {
138 pub move_id: Uuid,
139 pub done_qty: Decimal,
140 /// The minted backorder move (remaining demand), if any.
141 pub backorder_move_id: Option<Uuid>,
142 /// SLE rows minted by the valuation core (0 for value-neutral same-warehouse moves).
143 pub sle_count: i32,
144 /// The value the OUT leg carried off the source bin (COGS for a delivery shape).
145 pub out_value: Decimal,
146 /// The value the IN leg blended into the destination bin (the line amount for a receipt
147 /// shape).
148 pub in_value: Decimal,
149 pub gl_posted: bool,
150 pub gl_amount: Decimal,
151}
152
153/// Outcome of `_action_assign`.
154#[derive(Debug, Clone)]
155pub struct MoveAssignOutcome {
156 pub move_id: Uuid,
157 /// The move's post-assign state: `assigned` (fully reserved), `partially_available` (some
158 /// reserved), `confirmed` (nothing reservable at the source location).
159 pub state: String,
160 pub reserved_qty: Decimal,
161}
162
163/// The move lifecycle engine. An `impl InventoryWriteService` chunk over the vocabulary in
164/// [`super::inventory_write_service`] — the converged stock pipeline the routes/scheduler surfaces
165/// drive.
166impl InventoryWriteService {
167 // ---- create -------------------------------------------------------------
168
169 /// Create a DRAFT move (spec §1: `draft → waiting/confirmed` happens on confirm, never at
170 /// insert). Guards: R9 (src != dest), non-negative demand (R23 at the door), R13 (a quant
171 /// surface can never be a view location — checked for both endpoints that hold stock).
172 pub async fn create_move(&self, m: NewStockMove) -> Result<Uuid, InventoryError> {
173 if m.demand_qty < Decimal::ZERO || m.price_unit < Decimal::ZERO {
174 return Err(InventoryError::NegativeQuantity);
175 }
176 if m.location_id == m.location_dest_id {
177 return Err(InventoryError::SameLocation { move_id: Uuid::new_v4(), location_id: m.location_id });
178 }
179 let id = Uuid::new_v4();
180 let mut tx = self.rpool().begin().await?;
181 // Re-bind the caller's ambient org scope onto this transaction before the location
182 // reads (ADR-0029) — the scope is task-local and a fresh pool transaction carries none
183 // of it. Under the composed shape the decorator's fence bounds every read here;
184 // undecorated (module tests, jobs) the transaction stays plain.
185 relay_ambient_scope(&mut tx).await?;
186 let locs = self.moves.fetch_move_locations(&mut tx, m.location_id, m.location_dest_id).await?;
187 let (src, dst) = match locs {
188 (Some(s), Some(d)) => (s, d),
189 (None, _) => return Err(InventoryError::LocationNotFound(m.location_id)),
190 (_, None) => return Err(InventoryError::LocationNotFound(m.location_dest_id)),
191 };
192 for loc in [&src, &dst] {
193 if loc.usage == "view" {
194 return Err(InventoryError::ViewLocationHoldsNoStock { location_id: loc.id });
195 }
196 }
197 self.moves.insert_move(&mut tx, &NewMoveRow {
198 id,
199 name: &m.name,
200 item_id: m.item_id,
201 demand_qty: m.demand_qty,
202 price_unit: m.price_unit,
203 procure_method: &m.procure_method,
204 picking_id: m.picking_id,
205 origin: m.origin.as_deref(),
206 location_id: m.location_id,
207 location_dest_id: m.location_dest_id,
208 partner_id: m.partner_id,
209 warehouse_id: m.warehouse_id,
210 orderpoint_id: m.orderpoint_id,
211 move_orig_ids: m.move_orig_ids.clone(),
212 move_dest_ids: m.move_dest_ids.clone(),
213 is_inventory: m.is_inventory,
214 scrapped: m.scrapped,
215 forced_value: m.forced_value,
216 }).await?;
217 // Chain bookkeeping: a move naming its `move_orig_ids` parents gets the REVERSE link
218 // written too (each parent's move_dest_ids gains this move) — the done-qty propagation
219 // and waiting-release walk the parent's `move_dest_ids`, so both directions must exist
220 // (`link_chain` is idempotent per pair).
221 for parent in &m.move_orig_ids {
222 self.moves.link_chain(&mut tx, *parent, id).await?;
223 }
224 if let Some(picking) = m.picking_id {
225 self.moves.reproject_picking(&mut tx, picking).await?;
226 }
227 tx.commit().await?;
228 Ok(id)
229 }
230
231 // ---- _action_confirm ------------------------------------------------------
232
233 /// `draft → confirmed`, or `draft → waiting` while any `move_orig_ids` parent is not done
234 /// (spec §1: `waiting` is the state a chained move sits in until its parents land).
235 /// `procure_method` gates supply: `make_to_order` / `mts_else_mto` moves are minted by the
236 /// procurement (rule) engine, not here — this method only owns the state gate.
237 ///
238 /// The move read rides the caller's ambient org scope (ADR-0029): under the composed shape
239 /// the decorator's fence bounds it; undecorated (module tests, jobs) it is plain.
240 pub async fn action_confirm(&self, move_id: Uuid) -> Result<String, InventoryError> {
241 let mut tx = self.rpool().begin().await?;
242 relay_ambient_scope(&mut tx).await?;
243 let mv = self.moves.fetch_move(&mut tx, move_id).await?
244 .ok_or(InventoryError::NotFound(move_id))?;
245 if mv.state != "draft" {
246 return Err(InventoryError::WrongMoveState { move_id, action: "confirm", current: mv.state });
247 }
248 let to = self.confirm_core(&mut tx, &mv).await?;
249 tx.commit().await?;
250 if to == "confirmed" {
251 self.sink.publish(InventoryEvent::MoveConfirmed(MoveConfirmed {
252 move_id, company_id: legacy_company_echo(), item_id: mv.item_id,
253 demand_qty: mv.demand_qty, picking_id: mv.picking_id,
254 }));
255 }
256 Ok(to)
257 }
258
259 /// The confirm verb's core on the caller's connection (the ambient org scope already bound
260 /// — ADR-0029): the parents-done check, the guarded transition, and the picking reproject.
261 /// Shared by the public verb (own transaction) and the [`MovePipeline`] port the scheduler
262 /// drives on its per-batch connection.
263 async fn confirm_core(
264 &self,
265 conn: &mut sqlx::PgConnection,
266 mv: &MoveRow,
267 ) -> Result<String, InventoryError> {
268 let parents_done = if mv.move_orig_ids.is_empty() {
269 true
270 } else {
271 let parents = self.moves.fetch_moves(conn, &mv.move_orig_ids).await?;
272 parents.iter().all(|p| p.state == "done")
273 };
274 let to = if parents_done { "confirmed" } else { "waiting" };
275 let ok = self.moves.transition_state(conn, mv.id, "draft", to).await?;
276 if !ok {
277 return Err(InventoryError::WrongMoveState { move_id: mv.id, action: "confirm", current: "raced".into() });
278 }
279 if let Some(picking) = mv.picking_id {
280 self.moves.reproject_picking(conn, picking).await?;
281 }
282 Ok(to.into())
283 }
284
285 // ---- _action_assign -------------------------------------------------------
286
287 /// Reserve against quants at the source location (spec §1 `_action_assign`): the authoritative
288 /// `reserved_quantity` is written under each quant's `FOR UPDATE` (competing reservations
289 /// serialize there — one winner per unit of stock, R22/R25), a mirror move-line is minted per
290 /// reserved quant grain, and the move state aggregates the mirror.
291 ///
292 /// The move read rides the caller's ambient org scope (ADR-0029): under the composed shape
293 /// the decorator's fence bounds it; undecorated (module tests, jobs) it is plain.
294 pub async fn action_assign(&self, move_id: Uuid) -> Result<MoveAssignOutcome, InventoryError> {
295 let mut tx = self.rpool().begin().await?;
296 relay_ambient_scope(&mut tx).await?;
297 let mv = self.moves.fetch_move(&mut tx, move_id).await?
298 .ok_or(InventoryError::NotFound(move_id))?;
299 if mv.state != "confirmed" && mv.state != "partially_available" {
300 return Err(InventoryError::WrongMoveState { move_id, action: "assign", current: mv.state });
301 }
302 let (to, total) = self.assign_core(&mut tx, &mv).await?;
303 let picking = mv.picking_id;
304 tx.commit().await?;
305 if to == "assigned" {
306 self.sink.publish(InventoryEvent::MoveAssigned(MoveAssigned {
307 move_id, company_id: legacy_company_echo(), picking_id: picking,
308 }));
309 }
310 Ok(MoveAssignOutcome { move_id, state: to, reserved_qty: total })
311 }
312
313 /// The assign verb's core on the caller's connection (the ambient org scope already bound —
314 /// ADR-0029): the reservation loop, the mirror mint, the aggregate state transition, the
315 /// line re-mirror and the picking reproject. Returns `(new_state, reserved_qty)`. Shared by
316 /// the public verb and the [`MovePipeline`] port.
317 async fn assign_core(
318 &self,
319 conn: &mut sqlx::PgConnection,
320 mv: &MoveRow,
321 ) -> Result<(String, Decimal), InventoryError> {
322 let locs = self.moves.fetch_move_locations(conn, mv.location_id, mv.location_dest_id).await?;
323 let src = locs.0.ok_or(InventoryError::LocationNotFound(mv.location_id))?;
324 // Reservation only draws from INTERNAL stock; an inbound move (supplier source) has nothing
325 // to reserve and stays `confirmed`.
326 let mut reserved = Decimal::ZERO;
327 if src.usage == "internal" {
328 let already = self.move_lines.sum_mirror_qty(conn, mv.id).await?;
329 let mut remaining = mv.demand_qty - already;
330 let candidates = self.quants.fetch_reservation_candidates(conn, mv.item_id, mv.location_id).await?;
331 for cand in candidates {
332 if remaining <= Decimal::ZERO { break; }
333 let dims = QuantDims {
334 item_id: cand.item_id, location_id: cand.location_id,
335 lot_id: cand.lot_id, package_id: cand.package_id, owner_id: cand.owner_id,
336 };
337 // Lock the quant row, re-read the free availability under the lock (READ COMMITTED
338 // sees the competitor's committed reservation), then reserve the min(free, need).
339 let locked = self.quants.lock_or_init(conn, dims).await?;
340 let free = locked.available();
341 if free <= Decimal::ZERO { continue; }
342 let take = if free < remaining { free } else { remaining };
343 self.quants.adjust_reserved(conn, locked.id, take).await?;
344 self.move_lines.insert_line(conn, &NewMoveLineRow {
345 id: Uuid::new_v4(),
346 quantity: take,
347 lot_id: cand.lot_id,
348 package_id: cand.package_id,
349 result_package_id: None,
350 owner_id: cand.owner_id,
351 move_id: mv.id,
352 picking_id: mv.picking_id,
353 location_id: mv.location_id,
354 location_dest_id: mv.location_dest_id,
355 item_id: mv.item_id,
356 state: mv.state.as_str(), // transient: re-mirrored to the post-assign state below
357 }).await?;
358 reserved += take;
359 remaining -= take;
360 }
361 reserved += already;
362 } else {
363 // An INBOUND move (non-internal source — supplier/production) has no stock to
364 // reserve: its supply is unconditionally available, so `_action_assign` mints the
365 // execution line for the full remaining demand (Odoo's incoming-move readiness) —
366 // the line records what will land, with no quant touched at the source.
367 let already = self.move_lines.sum_mirror_qty(conn, mv.id).await?;
368 if already < mv.demand_qty {
369 self.move_lines.insert_line(conn, &NewMoveLineRow {
370 id: Uuid::new_v4(),
371 quantity: mv.demand_qty - already,
372 lot_id: None,
373 package_id: None,
374 result_package_id: None,
375 owner_id: None,
376 move_id: mv.id,
377 picking_id: mv.picking_id,
378 location_id: mv.location_id,
379 location_dest_id: mv.location_dest_id,
380 item_id: mv.item_id,
381 state: mv.state.as_str(), // transient: re-mirrored to the post-assign state below
382 }).await?;
383 }
384 reserved = mv.demand_qty;
385 }
386 let total = if mv.demand_qty < reserved { mv.demand_qty } else { reserved };
387 let to = if total >= mv.demand_qty && mv.demand_qty > Decimal::ZERO {
388 "assigned"
389 } else if total > Decimal::ZERO {
390 "partially_available"
391 } else {
392 "confirmed"
393 };
394 let ok = self.moves.transition_state(conn, mv.id, mv.state.as_str(), to).await?;
395 if !ok {
396 return Err(InventoryError::WrongMoveState { move_id: mv.id, action: "assign", current: "raced".into() });
397 }
398 self.move_lines.mirror_state(conn, mv.id, to).await?;
399 if let Some(picking) = mv.picking_id {
400 self.moves.reproject_picking(conn, picking).await?;
401 }
402 Ok((to.into(), total))
403 }
404
405 // ---- unreserve (the triangle's release arm) --------------------------------
406
407 /// Release every reservation the move holds (spec §1: cancel frees reservations first; assign
408 /// retries also release-then-re.reserve). The mirror lines zero out; the authoritative
409 /// `reserved_quantity` drops by exactly what the lines held.
410 ///
411 /// The move read rides the caller's ambient org scope (ADR-0029): under the composed shape
412 /// the decorator's fence bounds it; undecorated (module tests, jobs) it is plain.
413 pub async fn unreserve_move(&self, move_id: Uuid) -> Result<Decimal, InventoryError> {
414 let mut tx = self.rpool().begin().await?;
415 relay_ambient_scope(&mut tx).await?;
416 let mv = self.moves.fetch_move(&mut tx, move_id).await?
417 .ok_or(InventoryError::NotFound(move_id))?;
418 let released = self.unreserve_lines(&mut tx, &mv).await?;
419 tx.commit().await?;
420 Ok(released)
421 }
422
423 /// Internal: release all live reservations of a move inside the caller's transaction, by
424 /// zeroing the mirror lines and re-deriving each touched quant's authoritative counter from
425 /// the live mirror (the triangle's self-heal — a quant can never hold a reservation no live
426 /// line claims). Returns the quantity the lines held. The zeroed lines stay as the record of
427 /// what the move had reserved.
428 async fn unreserve_lines(
429 &self,
430 tx: &mut sqlx::PgConnection,
431 mv: &MoveRow,
432 ) -> Result<Decimal, InventoryError> {
433 let lines = self.move_lines.fetch_lines_for_move(tx, mv.id).await?;
434 let mut released = Decimal::ZERO;
435 let mut seen_dims: Vec<QuantDims> = Vec::new();
436 for line in lines {
437 if line.quantity > Decimal::ZERO {
438 released += line.quantity;
439 }
440 self.move_lines.set_quantity(tx, line.id, Decimal::ZERO).await?;
441 let dims = QuantDims {
442 item_id: line.item_id, location_id: line.location_id,
443 lot_id: line.lot_id, package_id: line.package_id, owner_id: line.owner_id,
444 };
445 if !seen_dims.contains(&dims) { seen_dims.push(dims); }
446 }
447 for dims in seen_dims {
448 let quant = self.quants.lock_or_init(tx, dims).await?;
449 self.quants.recompute_reserved_from_mirror(tx, quant.id, dims).await?;
450 }
451 Ok(released)
452 }
453
454 // ---- _action_done ----------------------------------------------------------
455
456 /// The mutation core (spec §4). See the module docs above for the full pipeline; the guards,
457 /// the two-step quant sync, the V7-ordered valuation, the chain propagation, the backorder
458 /// split and the projection all live here.
459 ///
460 /// The move read rides the caller's ambient org scope (ADR-0029): under the composed shape
461 /// the decorator's fence bounds it; undecorated (module tests, jobs) it is plain.
462 pub async fn action_done(
463 &self,
464 move_id: Uuid,
465 backorder: BackorderPolicy,
466 gl: &MoveGlDirective,
467 sink: &dyn GlPostSink,
468 ) -> Result<MoveDoneOutcome, InventoryError> {
469 let mut tx = self.rpool().begin().await?;
470 relay_ambient_scope(&mut tx).await?;
471 let mv = self.moves.fetch_move(&mut tx, move_id).await?
472 .ok_or(InventoryError::NotFound(move_id))?;
473 match mv.state.as_str() {
474 "assigned" | "partially_available" | "confirmed" => {}
475 other => return Err(InventoryError::WrongMoveState { move_id, action: "done", current: other.into() }),
476 }
477
478 let lines = self.move_lines.fetch_lines_for_move(&mut tx, move_id).await?;
479 if lines.is_empty() {
480 return Err(InventoryError::MoveLinesRequired { move_id }); // R24 at move grain
481 }
482 let mut done_qty = Decimal::ZERO;
483 for l in &lines {
484 if l.quantity < Decimal::ZERO {
485 return Err(InventoryError::NegativeQuantity); // R23
486 }
487 done_qty += l.quantity;
488 }
489 if done_qty <= Decimal::ZERO && mv.demand_qty > Decimal::ZERO {
490 return Err(InventoryError::MoveLinesRequired { move_id }); // nothing to validate
491 }
492
493 let locs = self.moves.fetch_move_locations(&mut tx, mv.location_id, mv.location_dest_id).await?;
494 let src = locs.0.ok_or(InventoryError::LocationNotFound(mv.location_id))?;
495 let dst = locs.1.ok_or(InventoryError::LocationNotFound(mv.location_dest_id))?;
496
497 // -- the done write FIRST, so the lines drop out of the live-reservation mirror before
498 // the reserved step re-derives the quant's counter (the two steps below are ordered
499 // by exactly this dependency) -------------------------------------------------------
500 let now = Utc::now();
501 let ok = self.moves.mark_done(&mut tx, move_id, mv.state.as_str(), done_qty, now).await?;
502 if !ok {
503 return Err(InventoryError::WrongMoveState { move_id, action: "done", current: "raced".into() });
504 }
505 self.move_lines.mirror_state(&mut tx, move_id, "done").await?;
506
507 // -- Step 1 of _synchronize_quant (the RESERVED step): re-derive each touched quant's
508 // authoritative reserved_quantity from the live mirror. Flipping this move's lines to
509 // `done` dropped them out of the SUM, so the reservation releases here — including any
510 // residual a shrunk line would otherwise strand. One recompute per DISTINCT source
511 // dimension tuple; quants that flip to zero on-hand keep their zeroed counter.
512 let mut seen_dims: Vec<QuantDims> = Vec::new();
513 for line in &lines {
514 if line.quantity <= Decimal::ZERO { continue; }
515 let src_dims = QuantDims {
516 item_id: line.item_id, location_id: line.location_id,
517 lot_id: line.lot_id, package_id: line.package_id, owner_id: line.owner_id,
518 };
519 if seen_dims.contains(&src_dims) { continue; }
520 seen_dims.push(src_dims);
521 let quant = self.quants.lock_or_init(&mut tx, src_dims).await?;
522 self.quants.recompute_reserved_from_mirror(&mut tx, quant.id, src_dims).await?;
523 }
524
525 // -- Step 2 of _synchronize_quant (the AVAILABLE step): the physical flip, guarded by R22
526 // at quant grain — the PHYSICAL on-hand must cover the draw (the reserved <= available
527 // invariant itself is held by the row lock + the CHECK backstop). Only an INTERNAL
528 // source is drawn from: the quant estate tracks our own stock, so an inbound move
529 // (supplier/production source) has no source quant to decrement — its supply materializes
530 // at the destination quant below.
531 let draw_from_src = src.usage == "internal";
532 for line in &lines {
533 if line.quantity <= Decimal::ZERO { continue; }
534 if draw_from_src {
535 let src_dims = QuantDims {
536 item_id: line.item_id, location_id: line.location_id,
537 lot_id: line.lot_id, package_id: line.package_id, owner_id: line.owner_id,
538 };
539 let src_quant = self.quants.lock_or_init(&mut tx, src_dims).await?;
540 if src_quant.quantity < line.quantity {
541 return Err(InventoryError::InsufficientStock {
542 item_id: mv.item_id,
543 warehouse_id: mv.warehouse_id.unwrap_or(mv.location_id),
544 available: src_quant.quantity,
545 requested: line.quantity,
546 });
547 }
548 self.quants.apply_qty(&mut tx, src_quant.id, -line.quantity, Decimal::ZERO).await?;
549 }
550 // Only an INTERNAL destination materializes a quant, the exact twin
551 // of the draw gate above: the quant estate tracks our own stock,
552 // so virtual counterparts (vendors, customers, production, transit,
553 // loss) keep NO balance — a move through them records its lines and
554 // its valuation, never a standing quant. The owner ruling of 2026-09-29;
555 // the previous shape (IN recorded, OUT dropped) left the counterparts
556 // as running tallies nothing could read.
557 if dst.usage == "internal" {
558 let dst_dims = QuantDims {
559 item_id: line.item_id, location_id: line.location_dest_id,
560 lot_id: line.lot_id, package_id: line.result_package_id.or(line.package_id), owner_id: line.owner_id,
561 };
562 let dst_quant = self.quants.lock_or_init(&mut tx, dst_dims).await?;
563 self.quants.apply_qty(&mut tx, dst_quant.id, line.quantity, Decimal::ZERO).await?;
564 }
565 }
566
567 // -- valuation core (V7: OUT valued before, IN after) --------------------------------
568 let (sle_count, out_value, in_value) = self
569 .mint_move_valuation(&mut tx, &mv, &src, &dst, done_qty)
570 .await?;
571
572 // -- GL arming (inside the movement's transaction) -----------------------------------
573 // Build the envelope BEFORE commit and arm the GL leg in the SAME transaction as the
574 // physical movement: a move either lands done with its GL leg `pending`, or not at all.
575 // A move that builds no envelope (voucher-door legs whose GL is owned by the voucher,
576 // value-neutral shapes, a directive without the accounts this leg needs, a `periodic`
577 // posture whose real-time posts are suppressed) stays `not_applicable` — the engine
578 // posts nothing on its behalf.
579 let posture = self.posting_posture_on(&mut tx).await?;
580 let envelope = self.move_gl_envelope(&mv, &src, &dst, gl, out_value, in_value, &posture);
581 if envelope.is_some() {
582 self.moves.set_posting_pending(&mut tx, move_id).await?;
583 }
584
585 // -- backorder split (partial validate) ----------------------------------------------
586 // Policy vocabulary (the operation type's `create_backorder`): the remainder becomes its
587 // own move on the SAME picking (the transfer stays open below `done` while it lives).
588 // Both minting policies CONFIRM the backorder — a confirmed move is what the
589 // scheduler's assign sweep and a re-validate can drive; a draft one is invisible to
590 // both. `Always` then reserves right away (reserve on mint); `Delayed` leaves the
591 // reservation to the scheduler's assign sweep (deferred reservation).
592 let mut backorder_move_id = None;
593 if done_qty < mv.demand_qty && backorder != BackorderPolicy::Never {
594 let remaining = mv.demand_qty - done_qty;
595 let child = Uuid::new_v4();
596 let backorder_name = format!("{}/BO", mv.name);
597 self.moves.insert_move(&mut tx, &NewMoveRow {
598 id: child,
599 name: &backorder_name,
600 item_id: mv.item_id,
601 demand_qty: remaining,
602 price_unit: mv.price_unit,
603 procure_method: &mv.procure_method,
604 picking_id: mv.picking_id,
605 origin: mv.origin.as_deref(),
606 location_id: mv.location_id,
607 location_dest_id: mv.location_dest_id,
608 partner_id: mv.partner_id,
609 warehouse_id: mv.warehouse_id,
610 orderpoint_id: mv.orderpoint_id,
611 move_orig_ids: vec![move_id],
612 move_dest_ids: mv.move_dest_ids.clone(),
613 is_inventory: mv.is_inventory,
614 scrapped: mv.scrapped,
615 forced_value: None, // fresh demand: ordinary valuation, never the parent's reversal carry
616 }).await?;
617 self.moves.link_chain(&mut tx, move_id, child).await?;
618 let child_row = self.moves.fetch_move(&mut tx, child).await?
619 .ok_or(InventoryError::NotFound(child))?;
620 self.confirm_core(&mut tx, &child_row).await?;
621 if backorder == BackorderPolicy::Always {
622 let fresh = self.moves.fetch_move(&mut tx, child).await?
623 .ok_or(InventoryError::NotFound(child))?;
624 if matches!(fresh.state.as_str(), "confirmed" | "partially_available") {
625 self.assign_core(&mut tx, &fresh).await?;
626 }
627 }
628 backorder_move_id = Some(child);
629 }
630
631 // -- done-qty propagation: release waiting children whose parents are all done --------
632 let mut released_children: Vec<crate::infrastructure::persistence::MoveRow> = Vec::new();
633 if !mv.move_dest_ids.is_empty() {
634 let children = self.moves.fetch_moves(&mut tx, &mv.move_dest_ids).await?;
635 for ch in &children {
636 if ch.state != "waiting" { continue; }
637 let parents = self.moves.fetch_moves(&mut tx, &ch.move_orig_ids).await?;
638 if parents.iter().all(|p| p.state == "done") {
639 let ok = self.moves.transition_state(&mut tx, ch.id, "waiting", "confirmed").await?;
640 if ok {
641 released_children.push(ch.clone());
642 }
643 }
644 }
645 }
646
647 let mut projected_state: Option<String> = None;
648 if let Some(picking) = mv.picking_id {
649 projected_state = self.moves.reproject_picking(&mut tx, picking).await?;
650 }
651 let picking = mv.picking_id;
652 tx.commit().await?;
653
654 // -- GL post (eventually consistent — the physical movement already committed) ---------
655 // Failure posture: a rejection does NOT roll the physical movement back and does NOT
656 // strand the hole silently — the move is marked `failed` (the durable, sweepable record
657 // of the missing GL leg) while the error still surfaces to the caller, and
658 // `repost_move_gl` re-drives the leg. A crash between this commit and the reconcile
659 // leaves the move `pending`; the same repost heals it. Accounting dedupes on the
660 // envelope's source identity, so a re-drive can never double post.
661 let mut gl_posted = false;
662 let mut gl_amount = Decimal::ZERO;
663 if let Some(env) = envelope {
664 debug_assert!(env.is_balanced());
665 gl_amount = env.lines.iter().map(|l| l.debit).sum();
666 match sink.post(&env).await {
667 Ok(_) => {
668 self.moves.mark_posting_posted(&self.rpool(), move_id).await?;
669 gl_posted = true;
670 }
671 Err(rej) => {
672 let _ = self.moves.mark_posting_failed(&self.rpool(), move_id).await;
673 return Err(InventoryError::GlRejected { code: rej.code, message: rej.message });
674 }
675 }
676 }
677
678 // -- events ----------------------------------------------------------------------------
679 self.sink.publish(InventoryEvent::MoveDone(MoveDone {
680 move_id, company_id: legacy_company_echo(), item_id: mv.item_id,
681 quantity: done_qty, price_unit: mv.price_unit, is_inventory: mv.is_inventory,
682 }));
683 if let Some(child) = backorder_move_id {
684 self.sink.publish(InventoryEvent::BackorderCreated(BackorderCreated {
685 backorder_id: child, company_id: legacy_company_echo(), origin_id: move_id,
686 }));
687 }
688 for ch in released_children {
689 self.sink.publish(InventoryEvent::MoveConfirmed(MoveConfirmed {
690 move_id: ch.id, company_id: legacy_company_echo(), item_id: ch.item_id,
691 demand_qty: ch.demand_qty, picking_id: ch.picking_id,
692 }));
693 }
694 if let Some(p) = picking {
695 if let Some(state) = projected_state {
696 self.sink.publish(InventoryEvent::TransferProjected(TransferProjected {
697 transfer_id: p, company_id: legacy_company_echo(), state, previous_state: mv.state.clone(),
698 }));
699 }
700 }
701 Ok(MoveDoneOutcome { move_id, done_qty, backorder_move_id, sle_count, out_value, in_value, gl_posted, gl_amount })
702 }
703
704 // ---- _action_cancel --------------------------------------------------------
705
706 /// `* → cancel` (spec §1): frees the reservation first, then propagates to the chained
707 /// children (`move_dest_ids`) unless `propagate_cancel=false` — never into a done move.
708 ///
709 /// The move read rides the caller's ambient org scope (ADR-0029): under the composed shape
710 /// the decorator's fence bounds it; undecorated (module tests, jobs) it is plain.
711 pub async fn action_cancel(&self, move_id: Uuid) -> Result<Decimal, InventoryError> {
712 let mut tx = self.rpool().begin().await?;
713 relay_ambient_scope(&mut tx).await?;
714 let mv = self.moves.fetch_move(&mut tx, move_id).await?
715 .ok_or(InventoryError::NotFound(move_id))?;
716 match mv.state.as_str() {
717 "draft" | "waiting" | "confirmed" | "partially_available" | "assigned" => {}
718 other => return Err(InventoryError::WrongMoveState { move_id, action: "cancel", current: other.into() }),
719 }
720 let released = self.unreserve_lines(&mut tx, &mv).await?;
721 let ok = self.moves.transition_state(&mut tx, move_id, mv.state.as_str(), "cancel").await?;
722 if !ok {
723 return Err(InventoryError::WrongMoveState { move_id, action: "cancel", current: "raced".into() });
724 }
725 self.move_lines.mirror_state(&mut tx, move_id, "cancel").await?;
726 // Down the chain: every live descendant frees its own reservation and cancels, and goes on
727 // to its own children while it propagates. A done move stops the walk on its branch; the
728 // visited set keeps a chain that loops back from cancelling a move twice.
729 let mut pickings: std::collections::BTreeSet<Uuid> = mv.picking_id.into_iter().collect();
730 let mut cancelled_children: Vec<(Uuid, Decimal)> = Vec::new();
731 let mut visited: std::collections::HashSet<Uuid> = std::collections::HashSet::from([move_id]);
732 let mut frontier: Vec<Uuid> = if mv.propagate_cancel { mv.move_dest_ids.clone() } else { Vec::new() };
733 while !frontier.is_empty() {
734 frontier.retain(|id| visited.insert(*id));
735 if frontier.is_empty() { break; }
736 let children = self.moves.fetch_moves(&mut tx, &frontier).await?;
737 frontier = Vec::new();
738 for ch in children {
739 if matches!(ch.state.as_str(), "done" | "cancel") { continue; }
740 let freed = self.unreserve_lines(&mut tx, &ch).await?;
741 if !self.moves.transition_state(&mut tx, ch.id, ch.state.as_str(), "cancel").await? {
742 return Err(InventoryError::WrongMoveState { move_id: ch.id, action: "cancel", current: "raced".into() });
743 }
744 self.move_lines.mirror_state(&mut tx, ch.id, "cancel").await?;
745 pickings.extend(ch.picking_id);
746 cancelled_children.push((ch.id, freed));
747 if ch.propagate_cancel { frontier.extend(ch.move_dest_ids.iter().copied()); }
748 }
749 }
750 for picking in pickings {
751 self.moves.reproject_picking(&mut tx, picking).await?;
752 }
753 tx.commit().await?;
754 self.sink.publish(InventoryEvent::MoveCancelled(MoveCancelled {
755 move_id, company_id: legacy_company_echo(), released_qty: released,
756 }));
757 for (child, freed) in cancelled_children {
758 self.sink.publish(InventoryEvent::MoveCancelled(MoveCancelled {
759 move_id: child, company_id: legacy_company_echo(), released_qty: freed,
760 }));
761 }
762 Ok(released)
763 }
764
765 // ---- GL repost: re-drive a stuck move GL leg ---------------------------------
766
767 /// Re-drive the GL leg of a done move whose `posting_state` is `pending` or `failed` — a
768 /// rejected or interrupted AccountingPost from `_action_done`. The physical movement
769 /// (quants, Bin, SLE rows) already happened and is never re-touched: the envelope is rebuilt
770 /// from the move's committed ledger legs plus the caller's account directive, re-emitted
771 /// under the move's ORIGINAL source identity (`source_type='inventory'`, `source_id` = move
772 /// id, `posting_type='original'`), and accounting's dedupe on that identity makes the
773 /// re-drive idempotent — a leg that actually landed before the status write was lost returns
774 /// the original journal instead of a second one.
775 ///
776 /// Short-circuits exactly like the voucher doors' repost verbs: `posted` → nothing to drive;
777 /// `not_applicable` → this move owns no GL leg (a voucher door posted its voucher's
778 /// envelope; a value-neutral shape posts nothing), so a sweep can call this blindly. If the
779 /// rebuilt envelope is `None` (the directive no longer supplies the accounts this leg shape
780 /// needs), the unsettled leg retires to `not_applicable` — the GL is genuinely not postable
781 /// under the current configuration and the move leaves the sweep worklist.
782 ///
783 /// On a fresh rejection the move parks in `failed` again and the error surfaces, as in
784 /// `_action_done`.
785 ///
786 /// The move read rides the caller's ambient org scope (ADR-0029): under the composed shape
787 /// the decorator's fence bounds it; undecorated (module tests, jobs) it is plain.
788 pub async fn repost_move_gl(
789 &self,
790 move_id: Uuid,
791 gl: &MoveGlDirective,
792 sink: &dyn GlPostSink,
793 ) -> Result<SubmitOutcome, InventoryError> {
794 let mv = {
795 let mut tx = self.rpool().begin().await?;
796 relay_ambient_scope(&mut tx).await?;
797 let mv = self.moves.fetch_move(&mut tx, move_id).await?
798 .ok_or(InventoryError::NotFound(move_id))?;
799 tx.commit().await?;
800 mv
801 };
802 match mv.posting_state.as_str() {
803 "posted" => return Ok(SubmitOutcome {
804 voucher_id: move_id, posted: true, journal_id: None, post_id: None,
805 gl_amount: Decimal::ZERO,
806 }),
807 "not_applicable" => return Ok(SubmitOutcome {
808 voucher_id: move_id, posted: false, journal_id: None, post_id: None,
809 gl_amount: Decimal::ZERO,
810 }),
811 _ => {} // pending | failed → re-drive below
812 }
813 if mv.state != "done" {
814 // posting_state only leaves not_applicable inside a done transaction, so this is a
815 // defensive guard — but a hand-mangled row should fail loudly, not re-post.
816 return Err(InventoryError::WrongMoveState { move_id, action: "repost_gl", current: mv.state });
817 }
818 let locs = {
819 let mut tx = self.rpool().begin().await?;
820 relay_ambient_scope(&mut tx).await?;
821 let locs = self.moves.fetch_move_locations(&mut tx, mv.location_id, mv.location_dest_id).await?;
822 tx.commit().await?;
823 locs
824 };
825 let src = locs.0.ok_or(InventoryError::LocationNotFound(mv.location_id))?;
826 let dst = locs.1.ok_or(InventoryError::LocationNotFound(mv.location_dest_id))?;
827 // The SAME posture the original done-transaction consulted: a `periodic` posture's
828 // move legs retire to `not_applicable` (the real-time post is genuinely not wanted
829 // under the current configuration), exactly like a directive that no longer supplies
830 // the accounts.
831 let posture = self.posting_posture().await?;
832 if posture.periodic {
833 self.moves.mark_posting_not_applicable(&self.rpool(), move_id).await?;
834 return Ok(SubmitOutcome {
835 voucher_id: move_id, posted: false, journal_id: None, post_id: None,
836 gl_amount: Decimal::ZERO,
837 });
838 }
839 let (out_value, in_value) = self.sles.move_leg_values(&self.rpool(), move_id).await?;
840 let envelope = self.move_gl_envelope(&mv, &src, &dst, gl, out_value, in_value, &posture);
841 let Some(env) = envelope else {
842 self.moves.mark_posting_not_applicable(&self.rpool(), move_id).await?;
843 return Ok(SubmitOutcome {
844 voucher_id: move_id, posted: false, journal_id: None, post_id: None,
845 gl_amount: Decimal::ZERO,
846 });
847 };
848 debug_assert!(env.is_balanced());
849 let gl_amount = env.lines.iter().map(|l| l.debit).sum();
850 match sink.post(&env).await {
851 Ok(ack) => {
852 self.moves.mark_posting_posted(&self.rpool(), move_id).await?;
853 Ok(SubmitOutcome {
854 voucher_id: move_id, posted: true,
855 journal_id: Some(ack.journal_id), post_id: Some(ack.post_id),
856 gl_amount,
857 })
858 }
859 Err(rej) => {
860 let _ = self.moves.mark_posting_failed(&self.rpool(), move_id).await;
861 Err(InventoryError::GlRejected { code: rej.code, message: rej.message })
862 }
863 }
864 }
865
866 // ---- valuation core (private) -----------------------------------------------
867
868 /// The Bin/SLE half of `_action_done` — the moving-average engine over the move's legs, with
869 /// the W1-proven arithmetic (receipt blends, delivery consumes, transfer carries; residual
870 /// flush when a bin drains to zero) and the V7 ordering (OUT valued from the locked src
871 /// snapshot BEFORE the dest reblend). Returns `(sle_count, out_value, in_value)`.
872 ///
873 /// Skips valuation entirely (returns zeros) when neither side resolves to an internal
874 /// warehouse bin, and for same-warehouse moves (the bin grain is (item, warehouse): a move
875 /// inside one warehouse changes no balance — the quant flips above carry the physical truth).
876 async fn mint_move_valuation(
877 &self,
878 tx: &mut sqlx::PgConnection,
879 mv: &crate::infrastructure::persistence::MoveRow,
880 src: &crate::infrastructure::persistence::LocationFacts,
881 dst: &crate::infrastructure::persistence::LocationFacts,
882 qty: Decimal,
883 ) -> Result<(i32, Decimal, Decimal), InventoryError> {
884 let src_wh = if src.usage == "internal" { src.warehouse_id } else { None };
885 let dst_wh = if dst.usage == "internal" { dst.warehouse_id } else { None };
886 if src_wh.is_none() && dst_wh.is_none() { return Ok((0, Decimal::ZERO, Decimal::ZERO)); }
887 if let (Some(a), Some(b)) = (src_wh, dst_wh) {
888 if a == b { return Ok((0, Decimal::ZERO, Decimal::ZERO)); }
889 }
890 let posting_date = Utc::now().date_naive();
891 let mut sle_no = self.sles.fetch_max_sle_no(tx, "stock_entry", mv.id).await?;
892
893 // Lock BOTH bins in canonical warehouse order (the deadlock rule proven by the transfer
894 // path: opposing moves on the same pair serialize instead of deadlocking).
895 let mut legs: Vec<(Uuid, crate::infrastructure::persistence::BinBalanceRow)> = Vec::new();
896 for wh in [src_wh, dst_wh].into_iter().flatten() {
897 let bal = self.bins.lock_or_init(tx, mv.item_id, wh).await?;
898 legs.push((wh, bal));
899 }
900 // `BinBalanceRow` is not Clone; `Decimal` is Copy, so reconstruct on lookup.
901 let balance_of = |wh: Uuid| {
902 legs.iter().find(|(w, _)| *w == wh).map(|(_, b)| crate::infrastructure::persistence::BinBalanceRow {
903 actual_qty: b.actual_qty, valuation_rate: b.valuation_rate, stock_value: b.stock_value,
904 })
905 };
906
907 let mut out_value = Decimal::ZERO;
908 let mut in_value = Decimal::ZERO;
909
910 // OUT leg (V7: valued BEFORE the IN reblend, at the src bin's pre-move average).
911 if let (Some(wh), Some(bal)) = (src_wh, src_wh.and_then(balance_of)) {
912 let out_qty = bal.actual_qty - qty;
913 if out_qty < Decimal::ZERO {
914 return Err(InventoryError::InsufficientStock {
915 item_id: mv.item_id, warehouse_id: wh,
916 available: bal.actual_qty, requested: qty,
917 });
918 }
919 // A forced value (a document reversal at its original amount) overrides the
920 // average/drain-derived carry — and reblends the remaining rate, because the
921 // remaining estate's value changed by an amount the old average does not describe.
922 // Ordinary moves: residual-flush rule (draining the bin to 0 carries its entire
923 // remaining value) or the current 2dp-rounded average.
924 let forced = mv.forced_value.is_some();
925 out_value = match mv.forced_value {
926 Some(fv) => fv,
927 None if out_qty.is_zero() => bal.stock_value,
928 None => money(qty * bal.valuation_rate),
929 };
930 let out_stock_value = bal.stock_value - out_value;
931 let out_rate = if out_qty > Decimal::ZERO {
932 if forced { rate6(out_stock_value / out_qty) } else { bal.valuation_rate }
933 } else { Decimal::ZERO };
934 self.bins.update_balance(tx, mv.item_id, wh, out_qty, out_rate, out_stock_value).await?;
935 sle_no += 1;
936 self.sles.insert_sle(tx, &crate::infrastructure::persistence::NewSleRow {
937 item_id: mv.item_id, warehouse_id: wh, posting_date,
938 actual_qty: -qty, qty_after_txn: out_qty, incoming_rate: Decimal::ZERO,
939 valuation_rate: out_rate, stock_value: out_stock_value,
940 stock_value_difference: -out_value,
941 voucher_type: "stock_entry", voucher_id: mv.id, voucher_no: &mv.name, sle_no,
942 }).await?;
943 }
944
945 // IN leg (V7: valued AFTER — reblends the destination with the carried value).
946 if let (Some(wh), Some(bal)) = (dst_wh, dst_wh.and_then(balance_of)) {
947 // Carried value: a forced value (a document reversal at its original amount)
948 // overrides everything; an external source (receipt) values the inflow at the
949 // move's unit price; an internal transfer carries the OUT value. A zero price on
950 // an external receive carries the destination's current average (value-neutral
951 // receive).
952 let carried = if let Some(fv) = mv.forced_value {
953 fv
954 } else if src.usage != "internal" {
955 if mv.price_unit > Decimal::ZERO { money(qty * mv.price_unit) } else { money(qty * bal.valuation_rate) }
956 } else {
957 out_value
958 };
959 in_value = carried;
960 let in_qty = bal.actual_qty + qty;
961 let in_stock_value = bal.stock_value + carried;
962 let in_rate = if in_qty > Decimal::ZERO { rate6(in_stock_value / in_qty) } else { Decimal::ZERO };
963 self.bins.update_balance(tx, mv.item_id, wh, in_qty, in_rate, in_stock_value).await?;
964 sle_no += 1;
965 self.sles.insert_sle(tx, &crate::infrastructure::persistence::NewSleRow {
966 item_id: mv.item_id, warehouse_id: wh, posting_date,
967 actual_qty: qty, qty_after_txn: in_qty, incoming_rate: mv.price_unit,
968 valuation_rate: in_rate, stock_value: in_stock_value,
969 stock_value_difference: carried,
970 voucher_type: "stock_entry", voucher_id: mv.id, voucher_no: &mv.name, sle_no,
971 }).await?;
972 }
973
974 Ok((sle_no, out_value, in_value))
975 }
976
977 // ---- landed-cost revaluation (the one non-move valuation write) --------------------------
978
979 /// Revalue a DONE receipt move's bin by `delta`: ONE value-only SLE row
980 /// (`voucher_type='landed_cost'`, `voucher_id` = the landed cost, qty 0, value `delta`)
981 /// plus the Bin value reblend (`value += delta; qty unchanged; rate = value/qty`).
982 ///
983 /// **RETROACTIVE-REVALUATION ASYMMETRY — deliberate, preserved.** `delta` is the
984 /// REMAINING-share portion of the move's landed-cost allocation
985 /// (`delta = allocation x remaining_qty / done_qty`, computed by the landed-cost door from a
986 /// read-only FIFO attribution over the SLE history). The already-CONSUMED portion of the
987 /// allocation produces NO correcting entry on this path — no COGS true-up, no journal leg,
988 /// no negative-SLE compensation — even though the consumed units also "cost more" now. This
989 /// one-sided revalue of already-done moves is the reference ERP's behavior and the
990 /// valuation-overlay plan row records it as an explicit keep: the consumed share's cost is
991 /// accepted as recognized-at-the-old-average at consumption time, and only the stock still
992 /// on hand revalues. Both GL sides of the landed-cost post carry only the remaining-portion
993 /// value so the post balances without inventing the missing COGS leg. Reversal is the
994 /// negative-amount landed-cost pattern (swapped legs verbatim), never a cancel.
995 ///
996 /// The one-writer invariant holds: this verb is the ONLY non-engine-internal caller path
997 /// that reaches `insert_sle`/`update_balance`, and it lives HERE, inside the engine, beside
998 /// [`Self::mint_move_valuation`] — the landed-cost door calls the verb, never the writers.
999 /// The engine posts NO GL for this leg (the landed-cost document owns its own envelope).
1000 ///
1001 /// Crash-resume: the SLE row's name is deterministic
1002 /// (`{lc_number}/{move_name}/{move_line_id}` — the move line id is the stable target grain;
1003 /// worksheet row ids are transient and must not appear in ledger names), so a re-drive of a
1004 /// crashed validation finds the row via [`Self::has_sle_named`] semantics and skips it,
1005 /// exactly like a door resuming a half-landed submit. Takes the CALLER'S connection: the
1006 /// revaluation SLE, the bin reblend and the landed-cost document's state flip commit as one
1007 /// unit. Returns `Ok(())` without minting when `delta` is zero (nothing to revalue) or the
1008 /// row already landed.
1009 pub(super) async fn adjust_move_value(
1010 &self,
1011 tx: &mut sqlx::PgConnection,
1012 mv: &crate::infrastructure::persistence::MoveRow,
1013 move_line_id: Uuid,
1014 delta: Decimal,
1015 lc_id: Uuid,
1016 lc_number: &str,
1017 ) -> Result<(), InventoryError> {
1018 if delta.is_zero() {
1019 return Ok(()); // an all-consumed target line revalues nothing
1020 }
1021 // The bin grain is (item, warehouse): resolve the warehouse the move's IN leg blended
1022 // into — the destination location's warehouse, the same resolution mint_move_valuation
1023 // used, so the reblend lands on the bin the original inflow created.
1024 let locs = self.moves.fetch_move_locations(tx, mv.location_id, mv.location_dest_id).await?;
1025 let dst = locs.1.ok_or(InventoryError::LocationNotFound(mv.location_dest_id))?;
1026 if dst.usage != "internal" {
1027 // The target set is the receipt's DONE moves that blended value into a bin; a move
1028 // whose destination is not an internal warehouse minted no valuation to revalue.
1029 return Err(InventoryError::LandedCostNoValuationAccount { move_id: mv.id });
1030 }
1031 let wh = dst.warehouse_id
1032 .ok_or(InventoryError::LandedCostNoValuationAccount { move_id: mv.id })?;
1033 let name = format!("{}/{}/{}", lc_number, mv.name, move_line_id);
1034 if self.sles.has_sle_named(tx, "landed_cost", lc_id, &name).await? {
1035 return Ok(()); // this leg already landed in a prior (crashed) attempt — resume, never double-mint
1036 }
1037 let bal = self.bins.lock_or_init(tx, mv.item_id, wh).await?;
1038 let new_value = bal.stock_value + delta;
1039 let new_qty = bal.actual_qty; // a landed cost moves no quantity, only value
1040 let new_rate = if new_qty > Decimal::ZERO {
1041 rate6(new_value / new_qty)
1042 } else {
1043 Decimal::ZERO
1044 };
1045 self.bins.update_balance(tx, mv.item_id, wh, new_qty, new_rate, new_value).await?;
1046 let sle_no = self.sles.fetch_max_sle_no(tx, "landed_cost", lc_id).await? + 1;
1047 self.sles.insert_sle(tx, &crate::infrastructure::persistence::NewSleRow {
1048 item_id: mv.item_id, warehouse_id: wh,
1049 posting_date: chrono::Utc::now().date_naive(),
1050 actual_qty: Decimal::ZERO, qty_after_txn: new_qty,
1051 incoming_rate: Decimal::ZERO, valuation_rate: new_rate,
1052 stock_value: new_value, stock_value_difference: delta,
1053 voucher_type: "landed_cost", voucher_id: lc_id, voucher_no: &name, sle_no,
1054 }).await?;
1055 Ok(())
1056 }
1057
1058 /// Build the GL envelope for the done move's leg shape, or `None` when the shape posts no GL
1059 /// (cross-warehouse internal transfer: value-neutral), the directive lacks the accounts, the
1060 /// `periodic` posture suppresses real-time stock posts, or the move carries neither value
1061 /// nor quantity (the explicit account-move gate).
1062 fn move_gl_envelope(
1063 &self,
1064 mv: &crate::infrastructure::persistence::MoveRow,
1065 src: &crate::infrastructure::persistence::LocationFacts,
1066 dst: &crate::infrastructure::persistence::LocationFacts,
1067 gl: &MoveGlDirective,
1068 out_value: Decimal,
1069 in_value: Decimal,
1070 posture: &Posture,
1071 ) -> Option<AccountingPostEnvelope> {
1072 // A `periodic` posture posts no real-time stock GL — the closing flow (a later
1073 // increment) owns those legs; the move stays `not_applicable`.
1074 if posture.periodic {
1075 return None;
1076 }
1077 // The explicit account-move gate (`_should_create_account_move` port): no envelope
1078 // when the move carries zero value AND zero qty, or the accounts its leg shape
1079 // requires are unset — both stay `not_applicable`.
1080 let shape_accounts_set = if mv.is_inventory {
1081 gl.inventory_account_id.is_some() && gl.adjustment_account_id.is_some()
1082 } else if src.usage == "internal" && dst.usage != "internal" {
1083 gl.cogs_account_id.is_some() && gl.inventory_account_id.is_some()
1084 } else if src.usage != "internal" && dst.usage == "internal" {
1085 gl.inventory_account_id.is_some() && gl.grir_account_id.is_some()
1086 } else {
1087 false // internal cross-warehouse: value-neutral, never posts
1088 };
1089 if !Self::should_create_account_move(out_value + in_value, mv.quantity, shape_accounts_set) {
1090 return None;
1091 }
1092 // The account-resolution chain for the inventory legs: the location's
1093 // valuation-account override when set, else the directive's account (the
1094 // door-header equivalent). Smallest-first override: a location beats the directive.
1095 let (description, lines) = if mv.is_inventory {
1096 // Adjustment shape (reconciliation vocabulary): the signed value diff between the
1097 // inventory account and the adjustment counterleg. The inventory leg resolves
1098 // the override of whichever side is the internal (counted) location.
1099 let inv = if dst.usage == "internal" { dst.valuation_account_id } else { src.valuation_account_id }
1100 .or(gl.inventory_account_id)?;
1101 let adj = gl.adjustment_account_id?;
1102 if in_value > out_value {
1103 ("Inventory adjustment".to_string(), vec![
1104 GlPostLine::debit(inv, in_value - out_value).with_description("Inventory"),
1105 GlPostLine::credit(adj, in_value - out_value).with_description("Adjustment"),
1106 ])
1107 } else if out_value > in_value {
1108 ("Inventory adjustment".to_string(), vec![
1109 GlPostLine::debit(adj, out_value - in_value).with_description("Adjustment"),
1110 GlPostLine::credit(inv, out_value - in_value).with_description("Inventory"),
1111 ])
1112 } else {
1113 return None;
1114 }
1115 } else if src.usage == "internal" && dst.usage != "internal" {
1116 // OUT (delivery shape): Dr COGS / Cr Inventory — the W1-proven leg. The inventory
1117 // leg prefers the SOURCE location's valuation-account override.
1118 ("Stock issue".to_string(), vec![
1119 GlPostLine::debit(gl.cogs_account_id?, out_value).with_description("COGS"),
1120 GlPostLine::credit(src.valuation_account_id.or(gl.inventory_account_id)?, out_value).with_description("Inventory"),
1121 ])
1122 } else if src.usage != "internal" && dst.usage == "internal" {
1123 // IN (receipt shape): Dr Inventory / Cr GR/IR. The inventory leg prefers the
1124 // DESTINATION location's valuation-account override.
1125 ("Goods receipt".to_string(), vec![
1126 GlPostLine::debit(dst.valuation_account_id.or(gl.inventory_account_id)?, in_value).with_description("Inventory"),
1127 GlPostLine::credit(gl.grir_account_id?, in_value).with_description("GR/IR clearing"),
1128 ])
1129 } else {
1130 // Internal cross-warehouse: value-neutral, no GL (the transfer-path contract).
1131 return None;
1132 };
1133 Some(AccountingPostEnvelope {
1134 idempotency_key: mv.id.to_string(),
1135 // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
1136 // consumers that still read a tenant off the wire. No module statement keys on it.
1137 company_id: legacy_company_echo(),
1138 branch_id: None,
1139 source_type: "inventory".into(),
1140 source_id: mv.id,
1141 source_reference: Some(mv.name.clone()),
1142 posting_date: Utc::now().date_naive(),
1143 currency: if gl.currency.is_empty() { "IDR".into() } else { gl.currency.clone() },
1144 posting_type: "original".into(),
1145 reverses_post_id: None,
1146 description: Some(description),
1147 lines,
1148 })
1149 }
1150}
1151
1152// --- the scheduler's MovePipeline port ------------------------------------------
1153
1154/// The write service IS the move engine, so it implements the port the daily scheduler drives
1155/// (`procurement_service::MovePipeline`): the per-move confirm/assign verbs on the CALLER'S
1156/// connection — the scheduler runs them inside its per-batch transaction, so a batch's
1157/// transitions commit or roll back together. Failures cross the boundary as a flat
1158/// `{code, message}` (the engine's own error taxonomy stays on this side of the port). Events
1159/// publish immediately through the advisory sink; the batch commit boundary is the scheduler's
1160/// concern.
1161#[async_trait]
1162impl MovePipeline for InventoryWriteService {
1163 async fn confirm(
1164 &self,
1165 conn: &mut sqlx::PgConnection,
1166 move_id: Uuid,
1167 ) -> Result<MoveState, MovePipelineError> {
1168 let pipe = |e: InventoryError| MovePipelineError { code: e.code(), message: e.to_string() };
1169 let mv = self
1170 .moves
1171 .fetch_move(conn, move_id)
1172 .await
1173 .map_err(|e| pipe(InventoryError::Db(e)))?
1174 .ok_or_else(|| pipe(InventoryError::NotFound(move_id)))?;
1175 if mv.state != "draft" {
1176 return Err(pipe(InventoryError::WrongMoveState { move_id, action: "confirm", current: mv.state }));
1177 }
1178 relay_ambient_scope(conn).await.map_err(|e| pipe(InventoryError::Db(e)))?;
1179 let to = self.confirm_core(conn, &mv).await.map_err(pipe)?;
1180 if to == "confirmed" {
1181 self.sink.publish(InventoryEvent::MoveConfirmed(MoveConfirmed {
1182 move_id,
1183 company_id: legacy_company_echo(),
1184 item_id: mv.item_id,
1185 demand_qty: mv.demand_qty,
1186 picking_id: mv.picking_id,
1187 }));
1188 }
1189 to.parse::<MoveState>()
1190 .map_err(|e| MovePipelineError { code: "bad_move_state".into(), message: e })
1191 }
1192
1193 async fn assign(
1194 &self,
1195 conn: &mut sqlx::PgConnection,
1196 move_id: Uuid,
1197 ) -> Result<MoveState, MovePipelineError> {
1198 let pipe = |e: InventoryError| MovePipelineError { code: e.code(), message: e.to_string() };
1199 let mv = self
1200 .moves
1201 .fetch_move(conn, move_id)
1202 .await
1203 .map_err(|e| pipe(InventoryError::Db(e)))?
1204 .ok_or_else(|| pipe(InventoryError::NotFound(move_id)))?;
1205 if mv.state != "confirmed" && mv.state != "partially_available" {
1206 return Err(pipe(InventoryError::WrongMoveState { move_id, action: "assign", current: mv.state }));
1207 }
1208 relay_ambient_scope(conn).await.map_err(|e| pipe(InventoryError::Db(e)))?;
1209 let (to, _reserved) = self.assign_core(conn, &mv).await.map_err(pipe)?;
1210 if to == "assigned" {
1211 self.sink.publish(InventoryEvent::MoveAssigned(MoveAssigned {
1212 move_id, company_id: legacy_company_echo(), picking_id: mv.picking_id,
1213 }));
1214 }
1215 to.parse::<MoveState>()
1216 .map_err(|e| MovePipelineError { code: "bad_move_state".into(), message: e })
1217 }
1218}