Skip to main content

backbone_inventory/application/service/
inventory_reconciliation.rs

1//! The ONE adjustment door + the StockReconciliation voucher over it (hand-authored,
2//! user-owned).
3//!
4//! **Quant-driven adjustment (spec stock-business-logic.md §5.2 — there is NO
5//! `stock.inventory` model).** A counted quantity STAGES on the quant row
6//! (`inventory_quantity`), the diff is a stored compute (`inventory_diff_quantity =
7//! counted − quantity`, T4), and `inventory_quantity_set` gates Apply. Applying mints an
8//! `is_inventory` move between the counted location and the inventory-loss location THROUGH
9//! the move engine ([`super::inventory_move_engine`]) — never a second writer of the quant:
10//! the engine's two-step `_synchronize_quant` flips the on-hand to exactly the count, mints
11//! the SLE pair, and reblends the Bin (the moving-average core owns the money path). The
12//! staged count is consumed on apply, so a second apply finds the gate down and is a NO-OP;
13//! a move landing between count and apply is refused LOUDLY as an outdated count (spec
14//! `is_outdated`), and a crash between the move's commit and the staging consume is
15//! recovered idempotently (on-hand already equals the count → consume, mint nothing).
16//!
17//! **The voucher converges onto the door.** `submit_reconciliation` keeps its document
18//! identity verbatim — header, item rows, the single value-diff `AccountingPost`
19//! (`Dr Inventory · Cr Adjustment` up / reversed down; net zero → `not_applicable`), the
20//! posting-state lifecycle, `repost_reconciliation`, and the `StockReconciled` event — but
21//! every line's physical movement is now quant-staged and move-minted through the door. The
22//! voucher's value-diff leg posts EXACTLY ONCE per voucher (idempotency key = the
23//! reconciliation id, deduped on `(company, source_type, source_id, posting_type)`); the
24//! per-line moves mint with a directive carrying no GL accounts, so the door never posts a
25//! second envelope under the voucher. A count carrying an explicit counted RATE is refused
26//! (the door values the diff at the current moving average — a rate revaluation is a
27//! valuation-overlay concern, never done silently here).
28//!
29//! Tenancy (ADR-0029): the module is tenant-agnostic. Every write transaction this file opens
30//! re-binds the caller's ambient org scope (`relay_ambient_scope`); the composing decorator owns
31//! isolation.
32//!
33//! Guard map (schema/hooks/stock.hook.yaml): R24 `no_count_while_reserved` (a quant holding
34//! reservations cannot be staged or applied), R13 (only stockable locations hold counts),
35//! T4 (the stored diff compute).
36//!
37//! Per the module's 4-layer rule this file holds no SQL — the statements live on
38//! [`super::super::super::infrastructure::persistence::StockAdjustmentRepository`] (quant
39//! lock / stage / consume), `StockPickingRepository` (location resolution, the
40//! mint-once/resume gate) and the engine's repositories; every write takes this service's
41//! transaction so the staging, the move mint, and the voucher rows commit in bounded units.
42
43use rust_decimal::Decimal;
44use uuid::Uuid;
45
46use crate::infrastructure::persistence::{
47    NewReconciliationItemRow, NewReconciliationRow, QuantSelector, StagedCountRow,
48};
49
50use super::inventory_events::{InventoryEvent, StockReconciled};
51use super::inventory_gl::{AccountingPostEnvelope, GlPostLine, GlPostSink};
52use super::inventory_move_engine::{BackorderPolicy, MoveGlDirective};
53use super::inventory_write_service::{
54    is_dup, legacy_company_echo, money, relay_ambient_scope, InventoryError,
55    InventoryWriteService, NewReconciliation, SubmitOutcome,
56};
57
58// --- door vocabulary ----------------------------------------------------------
59
60/// A staged count: the counted level, the on-hand it was staged against, and the stored
61/// diff compute (T4) now sitting on the quant row.
62#[derive(Debug, Clone)]
63pub struct StagedCount {
64    pub quant_id: Uuid,
65    pub location_id: Uuid,
66    pub on_hand_qty: Decimal,
67    pub counted_qty: Decimal,
68    pub diff_qty: Decimal,
69}
70
71/// The outcome of an apply: what the door did. `applied = false, move_id = None` is the
72/// IDEMPOTENT no-op (nothing staged, or the count already reconciled); `move_id = Some(_)`
73/// is the `is_inventory` move the door minted (or resumed) and validated.
74#[derive(Debug, Clone)]
75pub struct AppliedCount {
76    pub quant_id: Uuid,
77    pub location_id: Uuid,
78    pub counted_qty: Decimal,
79    pub diff_qty: Decimal,
80    pub move_id: Option<Uuid>,
81    pub applied: bool,
82}
83
84impl InventoryWriteService {
85    // ---- the adjustment door: stage -------------------------------------------
86
87    /// Stage a counted quantity on a quant (spec §5.2): the count is a LEVEL (the counted
88    /// on-hand), staged as `inventory_quantity` with its stored diff compute (T4) and the
89    /// `inventory_quantity_set` gate that [`Self::apply_inventory`] demands. Staging
90    /// OVERWRITES any prior staging — the count is not a delta, so re-counting replaces the
91    /// previous count. Guards: R24 (the quant holds no reservations), R13 (only internal
92    /// locations are countable). A quant that does not exist yet is initialized at zero —
93    /// the count surface exists even before any stock does.
94    pub async fn stage_quant_count(
95        &self,
96        item_id: Uuid,
97        location_id: Uuid,
98        counted_qty: Decimal,
99    ) -> Result<StagedCount, InventoryError> {
100        if counted_qty < Decimal::ZERO {
101            return Err(InventoryError::NegativeQuantity);
102        }
103        let mut tx = self.rpool().begin().await?;
104        // Re-bind the caller's ambient org scope before any read (ADR-0029) — the scope is
105        // task-local and a fresh pool transaction carries none of it; undecorated (module
106        // tests, jobs) the transaction stays plain.
107        relay_ambient_scope(&mut tx).await?;
108        let existing = self.adjustments.lock_quant(
109            &mut tx, QuantSelector::ItemLocation { item_id, location_id },
110        ).await?;
111        let quant = match existing {
112            Some(row) => {
113                self.check_countable(&row)?;
114                row
115            }
116            None => {
117                // Bootstrap the count surface: the location must be a stockable location
118                // (R13) before a quant may exist on it. The two-id facts read resolves the
119                // same location twice — it is a single-location lookup.
120                let locs = self.moves.fetch_move_locations(&mut tx, location_id, location_id).await?;
121                let facts = locs.0.ok_or(InventoryError::LocationNotFound(location_id))?;
122                if facts.usage == "view" {
123                    return Err(InventoryError::ViewLocationHoldsNoStock { location_id });
124                }
125                if facts.usage != "internal" {
126                    // View locations are the named R13 case; every other non-internal usage
127                    // (supplier/customer/inventory-loss/transit/production) holds no
128                    // countable stock of ours either — the shared error vocabulary covers
129                    // the whole class under the view-location code.
130                    return Err(InventoryError::ViewLocationHoldsNoStock { location_id });
131                }
132                let id = self.adjustments.init_quant(&mut tx, item_id, location_id).await?;
133                self.adjustments.lock_quant(&mut tx, QuantSelector::ById(id)).await?
134                    .ok_or(InventoryError::NotFound(id))?
135            }
136        };
137        let diff = counted_qty - quant.quantity;
138        self.adjustments.stage_count(&mut tx, quant.id, counted_qty, diff).await?;
139        tx.commit().await?;
140        Ok(StagedCount {
141            quant_id: quant.id,
142            location_id,
143            on_hand_qty: quant.quantity,
144            counted_qty,
145            diff_qty: diff,
146        })
147    }
148
149    // ---- the adjustment door: apply (_apply_inventory) -------------------------
150
151    /// Apply a staged count (spec §5.2 `_apply_inventory`): mint (or resume) the
152    /// `is_inventory` move that reconciles the quant's on-hand to the count, and validate
153    /// it through the engine pipeline — the move is the ONE writer of the quant's
154    /// `quantity`; this method never touches it. Idempotent by construction:
155    ///   - nothing staged (gate down) → no-op;
156    ///   - on-hand already equals the count (a crash between the move's commit and the
157    ///     staging consume, or offsetting activity) → consume the staging, mint nothing;
158    ///   - a live `is_inventory` move for the grain already exists (a crash mid-pipeline)
159    ///     → resume IT, never mint a second (the mint-once gate);
160    ///   - a move landed between the count and here → LOUD `OutdatedCount` (the staged
161    ///     diff no longer reconciles the count; re-stage against the new on-hand).
162    ///
163    /// GL: with accounts in `gl`, the engine posts the adjustment leg per move
164    /// (`Dr Inventory · Cr Adjustment` up / reversed down, idempotency key = move id); a
165    /// directive without accounts posts nothing (the physical movement is unaffected) —
166    /// the voucher path drives the door that way and posts its own single net envelope.
167    pub async fn apply_inventory(
168        &self,
169        selector: QuantSelector,
170        gl: &MoveGlDirective,
171        sink: &dyn GlPostSink,
172    ) -> Result<AppliedCount, InventoryError> {
173        // -- gate + guards under the quant's FOR UPDATE lock --------------------------------
174        let (quant_id, location_id, counted, staged_diff) = {
175            let mut tx = self.rpool().begin().await?;
176            relay_ambient_scope(&mut tx).await?;
177            let quant = self.adjustments.lock_quant(&mut tx, selector).await?
178                .ok_or_else(|| match selector {
179                    QuantSelector::ById(id) => InventoryError::NotFound(id),
180                    QuantSelector::ItemLocation { location_id, .. } =>
181                        InventoryError::LocationNotFound(location_id),
182                })?;
183            self.check_countable(&quant)?;
184            if !quant.inventory_quantity_set {
185                tx.commit().await?;
186                return Ok(AppliedCount {
187                    quant_id: quant.id, location_id: quant.location_id,
188                    counted_qty: quant.quantity, diff_qty: Decimal::ZERO,
189                    move_id: None, applied: false,
190                });
191            }
192            let counted = quant.inventory_quantity.unwrap_or(quant.quantity);
193            let staged_diff = quant.inventory_diff_quantity.unwrap_or(Decimal::ZERO);
194            if quant.quantity == counted {
195                // Already reconciled (crash-window recovery or offsetting activity): the
196                // on-hand IS the count — consume the staging, mint nothing.
197                self.adjustments.consume_staged_count(&mut tx, quant.id).await?;
198                tx.commit().await?;
199                return Ok(AppliedCount {
200                    quant_id: quant.id, location_id: quant.location_id,
201                    counted_qty: counted, diff_qty: Decimal::ZERO,
202                    move_id: None, applied: false,
203                });
204            }
205            let on_hand_at_stage = counted - staged_diff;
206            if quant.quantity != on_hand_at_stage {
207                return Err(InventoryError::OutdatedCount {
208                    quant_id: quant.id, counted_qty: counted, on_hand_qty: quant.quantity,
209                });
210            }
211            tx.commit().await?;
212            (quant.id, quant.location_id, counted, staged_diff)
213        };
214
215        // -- zero diff: the count matches the on-hand — consume, no move --------------------
216        if staged_diff == Decimal::ZERO {
217            let mut tx = self.rpool().begin().await?;
218            relay_ambient_scope(&mut tx).await?;
219            self.adjustments.consume_staged_count(&mut tx, quant_id).await?;
220            tx.commit().await?;
221            return Ok(AppliedCount {
222                quant_id, location_id, counted_qty: counted, diff_qty: Decimal::ZERO,
223                move_id: None, applied: true,
224            });
225        }
226
227        // -- resolve the inventory-loss location (the far end of every adjustment move) ----
228        let loss_location = {
229            let mut tx = self.rpool().begin().await?;
230            relay_ambient_scope(&mut tx).await?;
231            let loc = self.pickings.ensure_inventory_loss_location(&mut tx).await?;
232            tx.commit().await?;
233            loc
234        };
235
236        // -- mint-once / resume gate --------------------------------------------------------
237        let item_id = match selector {
238            QuantSelector::ById(_) => {
239                // The locked row carried it; re-read the grain off the quant we just locked.
240                let mut tx = self.rpool().begin().await?;
241                relay_ambient_scope(&mut tx).await?;
242                let quant = self.adjustments.lock_quant(&mut tx, QuantSelector::ById(quant_id)).await?
243                    .ok_or(InventoryError::NotFound(quant_id))?;
244                tx.commit().await?;
245                quant.item_id
246            }
247            QuantSelector::ItemLocation { item_id, .. } => item_id,
248        };
249        let existing = {
250            let mut tx = self.rpool().begin().await?;
251            relay_ambient_scope(&mut tx).await?;
252            let found = self.pickings.find_adjustment_moves(
253                &mut tx, item_id, location_id, loss_location,
254            ).await?;
255            tx.commit().await?;
256            found
257        };
258        let move_id = match existing.first() {
259            // A live adjustment move for this grain: resume it (a crash between mint and
260            // done leaves exactly this state — minting again would double-apply).
261            Some(prior) if prior.state != "done" && prior.state != "cancel" => prior.id,
262            _ => {
263                let (src, dst, qty) = if staged_diff > Decimal::ZERO {
264                    (loss_location, location_id, staged_diff)
265                } else {
266                    (location_id, loss_location, -staged_diff)
267                };
268                let warehouse = {
269                    let mut tx = self.rpool().begin().await?;
270                    relay_ambient_scope(&mut tx).await?;
271                    let locs = self.moves.fetch_move_locations(&mut tx, location_id, location_id).await?;
272                    tx.commit().await?;
273                    locs.0.and_then(|f| f.warehouse_id)
274                };
275                self.create_move(super::inventory_move_engine::NewStockMove {
276                    name: format!("ADJ/{}/{}", item_id.simple(), location_id.simple()),
277                    item_id,
278                    demand_qty: qty,
279                    price_unit: Decimal::ZERO, // the diff is valued at the current average
280                    procure_method: "make_to_stock".into(),
281                    picking_id: None,
282                    origin: None,
283                    location_id: src,
284                    location_dest_id: dst,
285                    partner_id: None,
286                    warehouse_id: warehouse,
287                    orderpoint_id: None,
288                    move_orig_ids: vec![],
289                    move_dest_ids: vec![],
290                    is_inventory: true,
291                    scrapped: false,
292                    forced_value: None, // ordinary demand: the diff is valued at the current average
293                }).await?
294            }
295        };
296
297        // -- drive the move to done through the engine pipeline -----------------------------
298        // Sequence the verbs, re-reading the state between them: the engine owns the state
299        // (this method never asserts it), and the assign step is what mints the execution
300        // line the done verb requires (R24 at move grain).
301        let mut state = self.move_state_of(move_id).await?;
302        if state == "draft" {
303            self.action_confirm(move_id).await?;
304            state = self.move_state_of(move_id).await?;
305        }
306        if state == "confirmed" || state == "partially_available" {
307            self.action_assign(move_id).await?;
308            state = self.move_state_of(move_id).await?;
309        }
310        if state != "assigned" {
311            // An adjustment must land WHOLE: a partial draw would leave the on-hand short of
312            // the count while the staging is gone. Loud refusal, staging survives for a retry.
313            return Err(InventoryError::WrongMoveState {
314                move_id, action: "apply", current: state,
315            });
316        }
317        self.action_done(move_id, BackorderPolicy::Never, gl, sink).await?;
318
319        // -- consume the staging (the gate drops AFTER the move landed) ---------------------
320        {
321            let mut tx = self.rpool().begin().await?;
322            relay_ambient_scope(&mut tx).await?;
323            self.adjustments.consume_staged_count(&mut tx, quant_id).await?;
324            tx.commit().await?;
325        }
326        Ok(AppliedCount {
327            quant_id, location_id, counted_qty: counted, diff_qty: staged_diff,
328            move_id: Some(move_id), applied: true,
329        })
330    }
331
332    /// The pending-count worklist at a location (the partial index
333    /// `WHERE inventory_quantity_set = true` serves this read): every staged-but-unapplied
334    /// count, with the stored diff compute. A probe — it derives nothing, writes nothing.
335    pub async fn staged_counts(
336        &self,
337        location_id: Uuid,
338    ) -> Result<Vec<StagedCountRow>, InventoryError> {
339        let mut tx = self.rpool().begin().await?;
340        relay_ambient_scope(&mut tx).await?;
341        let rows = self.adjustments.staged_counts_at_location(&mut tx, location_id).await?;
342        tx.commit().await?;
343        Ok(rows)
344    }
345
346    /// The guards every staging/apply shares: R24 (no count while the quant holds
347    /// reservations — release them first), R13 (only stockable locations hold counts).
348    fn check_countable(
349        &self,
350        quant: &crate::infrastructure::persistence::QuantCountRow,
351    ) -> Result<(), InventoryError> {
352        if quant.location_usage == "view" || quant.location_usage != "internal" {
353            return Err(InventoryError::ViewLocationHoldsNoStock { location_id: quant.location_id });
354        }
355        if quant.reserved_quantity > Decimal::ZERO {
356            return Err(InventoryError::CountReserved {
357                quant_id: quant.id, reserved_qty: quant.reserved_quantity,
358            });
359        }
360        Ok(())
361    }
362
363    // ---- submit: the StockReconciliation voucher over the door -------------------
364
365    /// Submit a physical-count voucher (converged onto the adjustment door). Document
366    /// identity verbatim: header + item rows + the single value-diff GL envelope + the
367    /// `StockReconciled` event. Physical: per line, the count stages on the warehouse stock
368    /// location's quant and applies through [`Self::apply_inventory`] — the minted
369    /// `is_inventory` moves carry the SLE/Bin legs (voucher-type `stock_entry`, voucher id
370    /// = the move id: the ledger contract is the append-only SLE + moving average, which
371    /// the engine owns). The voucher's GL leg posts exactly once (recon-keyed idempotency);
372    /// the door is driven with a no-accounts directive so no per-move GL duplicates it.
373    ///
374    /// Sequencing: the physical applications run FIRST, the voucher rows are recorded LAST
375    /// — a failure mid-way leaves no half-written voucher (the same number can be
376    /// re-submitted), and the door's idempotency (gate consumed per applied line; fresh
377    /// staging per unapplied line) makes the re-run converge instead of double-applying.
378    /// Not atomic across lines: an applied line's stock movement stays applied (the SLE is
379    /// append-only truth); the retry re-stages every line against the then-current on-hand.
380    pub async fn submit_reconciliation(&self, r: NewReconciliation, sink: &dyn GlPostSink) -> Result<Uuid, InventoryError> {
381        if r.lines.is_empty() { return Err(InventoryError::EmptyDocument); }
382        for l in &r.lines {
383            if l.counted_qty < Decimal::ZERO || l.counted_rate < Decimal::ZERO {
384                return Err(InventoryError::NegativeQuantity);
385            }
386            if l.counted_rate > Decimal::ZERO {
387                return Err(InventoryError::CountedRateUnsupported);
388            }
389        }
390
391        // The warehouse's stock location: the quant grain the counts stage on (the voucher
392        // grain was the warehouse; the door grain is the location — bootstrapped per
393        // warehouse on first use, the same resolution the transfer voucher uses).
394        let stock_location = {
395            let mut tx = self.rpool().begin().await?;
396            relay_ambient_scope(&mut tx).await?;
397            let loc = self.pickings.ensure_internal_location(&mut tx, r.warehouse_id).await?;
398            tx.commit().await?;
399            loc
400        };
401
402        // ---- physical: stage + apply per line through the door ---------------------------
403        let mut net = Decimal::ZERO;
404        let mut applied: Vec<(Uuid, Decimal, Decimal, Decimal)> = Vec::with_capacity(r.lines.len());
405        for l in &r.lines {
406            // Heal the quant surface if the stock predates the converged model (Bins
407            // without quants) — the door reads and writes the quant grain.
408            {
409                let mut tx = self.rpool().begin().await?;
410                relay_ambient_scope(&mut tx).await?;
411                self.pickings.ensure_quant_surface(
412                    &mut tx, l.item_id, stock_location, Some(r.warehouse_id),
413                ).await?;
414                tx.commit().await?;
415            }
416            // The pre-move moving average: the value-diff the door produces (the count is
417            // valued at the current average — a counted RATE is refused at the door).
418            let rate = {
419                let mut tx = self.rpool().begin().await?;
420                relay_ambient_scope(&mut tx).await?;
421                let bal = self.bins.lock_or_init(&mut tx, l.item_id, r.warehouse_id).await?;
422                tx.commit().await?;
423                bal.valuation_rate
424            };
425            let staged = self.stage_quant_count(l.item_id, stock_location, l.counted_qty).await?;
426            let outcome = self.apply_inventory(
427                QuantSelector::ItemLocation { item_id: l.item_id, location_id: stock_location },
428                &MoveGlDirective::default(), // no per-move GL: the voucher posts the one net envelope
429                sink,
430            ).await?;
431            let qty_diff = if outcome.applied { outcome.diff_qty } else { staged.diff_qty };
432            let value_diff = if qty_diff == Decimal::ZERO {
433                Decimal::ZERO
434            } else {
435                money(qty_diff * rate)
436            };
437            net += value_diff;
438            applied.push((l.item_id, l.counted_qty, qty_diff, value_diff));
439        }
440
441        // ---- voucher record: header + items + net, one transaction -----------------------
442        let id = Uuid::new_v4();
443        let mut tx = self.rpool().begin().await?;
444        relay_ambient_scope(&mut tx).await?;
445        let ins = self.recons.insert_submitted(&mut tx, &NewReconciliationRow {
446            id,
447            recon_number: &r.recon_number,
448            warehouse_id: r.warehouse_id,
449            posting_date: r.posting_date,
450            currency: &r.currency,
451            inventory_account_id: r.inventory_account_id,
452            adjustment_account_id: r.adjustment_account_id,
453        }).await;
454        if let Err(e) = ins {
455            return Err(if is_dup(&e) { InventoryError::DuplicateNumber(r.recon_number) } else { e.into() });
456        }
457        for (item_id, counted_qty, qty_diff, value_diff) in &applied {
458            self.recon_items.insert_item(&mut tx, &NewReconciliationItemRow {
459                id: Uuid::new_v4(),
460                reconciliation_id: id,
461                item_id: *item_id,
462                counted_qty: *counted_qty,
463                counted_rate: Decimal::ZERO,
464                qty_difference: *qty_diff,
465                value_difference: *value_diff,
466            }).await?;
467        }
468        self.recons.update_net_difference(&mut tx, id, net).await?;
469        tx.commit().await?;
470
471        // ---- GL: the single value-diff envelope, exactly as the proven contract ------------
472        if net != Decimal::ZERO {
473            // net > 0: stock increased → Dr Inventory · Cr Adjustment; net < 0: the reverse.
474            let (inv, adj) = (r.inventory_account_id, r.adjustment_account_id);
475            let lines = if net > Decimal::ZERO {
476                vec![GlPostLine::debit(inv, net).with_description("Inventory"), GlPostLine::credit(adj, net).with_description("Stock adjustment")]
477            } else {
478                let amt = -net;
479                vec![GlPostLine::debit(adj, amt).with_description("Stock adjustment"), GlPostLine::credit(inv, amt).with_description("Inventory")]
480            };
481            let env = AccountingPostEnvelope {
482                // Legacy twin (ADR-0029): filled from the ambient org scope's company echo
483                // for consumers that still read a tenant off the wire. No module statement
484                // keys on it.
485                idempotency_key: id.to_string(), company_id: legacy_company_echo(), branch_id: None,
486                source_type: "inventory".into(), source_id: id, source_reference: Some(r.recon_number.clone()),
487                posting_date: r.posting_date, currency: r.currency.clone(), posting_type: "original".into(), reverses_post_id: None,
488                description: Some("Stock reconciliation".into()), lines,
489            };
490            self.emit_and_reconcile(crate::infrastructure::persistence::GlVoucher::StockReconciliation, id, &env, sink, net.abs()).await?;
491        } else {
492            self.recons.mark_not_applicable(&self.rpool(), id).await?;
493        }
494        self.sink.publish(InventoryEvent::StockReconciled(StockReconciled {
495            reconciliation_id: id, company_id: legacy_company_echo(), warehouse_id: r.warehouse_id, net_difference: net,
496        }));
497        Ok(id)
498    }
499
500    // ---- repost: re-drive a stuck reconciliation post -----------------------------
501
502    /// Re-drive the GL leg for a reconciliation whose physical movement committed but whose post is
503    /// `pending` or `failed`, OR — the case this exists for — a `net==0` recon that crashed between
504    /// the voucher record and `mark_not_applicable` and is stuck in `(submitted, pending)`. Mirrors the
505    /// receipt/delivery repost: rebuilds from the stored header, never re-touches the physical
506    /// movement (the door's staging gate is consumed and the moves are done — the apply is a no-op,
507    /// so a repost can never double-apply the counts).
508    ///
509    /// `net != 0` → re-emit the value-diff envelope (idempotent: accounting dedupes on
510    /// `(company, source_type, source_id, posting_type)`); `net == 0` → reconcile to
511    /// `not_applicable`. Already-`posted`/`not_applicable` short-circuits via `already_settled`.
512    pub async fn repost_reconciliation(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
513        let h = self.recons.fetch_repost_header(&self.rpool(), id).await?
514            .ok_or(InventoryError::NotFound(id))?;
515        if let Some(o) = Self::already_settled(&h.gl, id) { return Ok(o); }
516
517        if h.net_difference.is_zero() {
518            // net==0 carries no value to post; the recovery is the mark_not_applicable that the
519            // crash skipped. No event re-publish — the physical movement already committed.
520            self.recons.mark_not_applicable(&self.rpool(), id).await?;
521            return Ok(SubmitOutcome {
522                voucher_id: id, posted: false, journal_id: None, post_id: None, gl_amount: Decimal::ZERO,
523            });
524        }
525
526        let net = h.net_difference;
527        let (inv, adj) = (h.inventory_account_id, h.adjustment_account_id);
528        let lines = if net > Decimal::ZERO {
529            vec![GlPostLine::debit(inv, net).with_description("Inventory"), GlPostLine::credit(adj, net).with_description("Stock adjustment")]
530        } else {
531            let amt = -net;
532            vec![GlPostLine::debit(adj, amt).with_description("Stock adjustment"), GlPostLine::credit(inv, amt).with_description("Inventory")]
533        };
534        let env = AccountingPostEnvelope {
535            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
536            // consumers that still read a tenant off the wire. No module statement keys on it.
537            idempotency_key: id.to_string(), company_id: legacy_company_echo(), branch_id: None,
538            source_type: "inventory".into(), source_id: id, source_reference: Some(h.recon_number.clone()),
539            posting_date: h.posting_date, currency: h.currency.clone(), posting_type: "original".into(), reverses_post_id: None,
540            description: Some("Stock reconciliation (repost)".into()), lines,
541        };
542        self.emit_and_reconcile(crate::infrastructure::persistence::GlVoucher::StockReconciliation, id, &env, sink, net.abs()).await
543    }
544}