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}