backbone_inventory/application/service/landed_cost_service_custom.rs
1//! Landed-cost documents (hand-authored, user-owned): draft → validate → done, cancel from
2//! draft only (hand_set state machine, ADR-0016 document vocabulary).
3//!
4//! An `impl InventoryWriteService` chunk over the vocabulary in
5//! [`super::inventory_write_service`]. A landed cost revalues the DONE moves of ONE target
6//! purchase receipt:
7//!
8//! 1. **Split** each cost line's amount over the target lines by its basis — `quantity`
9//! (done qty), `value` (carried value), or `weight` (done qty × the item master's
10//! per-unit weight). HALF-UP at the currency minor unit, the LAST target line (by
11//! `move_line_id`) eats the rounding diff. A basis that sums to ZERO across every target
12//! line is rejected LOUDLY ([`InventoryError::LandedCostZeroSplitBasis`]) — no silent
13//! equal-split fallback, no partial worksheet rows; the deliberate deviation from the
14//! reference ERP's quiet fallback, which masked unweighted item data.
15//! 2. **Revalue** the remaining stock through the move engine's adjustment verb
16//! ([`InventoryWriteService::adjust_move_value`]) — one value-only SLE row + bin reblend
17//! per target line. The engine stays the single SLE/bin writer; this door never touches
18//! `insert_sle`/`update_balance` itself.
19//! 3. **Post** ONE balanced envelope through the posting_state machinery the voucher doors
20//! use (`Dr inventory valuation / Cr per cost line account`), both sides carrying only the
21//! remaining-share value.
22//!
23//! **Retroactive-revaluation asymmetry (deliberate, preserved).** Only the still-on-hand
24//! portion of a target line revalues: `delta = allocation × remaining_qty / done_qty`, where
25//! `remaining_qty` is a read-only FIFO ATTRIBUTION over the SLE history (attribution, NOT
26//! FIFO costing — the moving-average engine stays the only costing writer). The
27//! already-consumed portion produces NO correcting entry — no COGS true-up, no journal leg.
28//! See the full rationale at the engine verb's site ([`super::inventory_move_engine`]).
29//!
30//! **Worksheet, not a ledger.** The allocation worksheet (`landed_cost_adjustment_lines`) is
31//! deleted and recreated on every validation, rows written ordered by `move_line_id` so the
32//! last-line-eats-the-diff recipient is deterministic. The only accounting identity is the
33//! posted journal entry; the worksheet is a read-back surface (and the GL repost's
34//! credit-split source).
35//!
36//! **Reversal pattern.** A `done` landed cost can NEVER cancel — a correction is a
37//! negative-amount landed cost whose debit/credit legs are swapped verbatim (a negative
38//! document lowers the bin value and posts Dr cost lines / Cr inventory).
39//!
40//! Per the module's 4-layer rule this file holds no SQL — the statements live on
41//! [`crate::infrastructure::persistence::ValuationOverlayRepository`] and the engine's
42//! repositories.
43//!
44//! Tenancy (ADR-0029): the module is tenant-agnostic. Every write transaction this file opens
45//! re-binds the caller's ambient org scope (`relay_ambient_scope`); the composing decorator owns
46//! isolation. Cross-module wire fields that still carry a company id are legacy twins filled
47//! from the ambient scope's company echo.
48
49use rust_decimal::Decimal;
50use std::collections::HashMap;
51use uuid::Uuid;
52
53use crate::infrastructure::persistence::{
54 GlVoucher, LcLineRow, MoveRow, NewLcLineRow, NewLandedCostRow, NewWorksheetRow,
55};
56
57use super::inventory_events::{InventoryEvent, LandedCostValidated};
58use super::inventory_gl::{AccountingPostEnvelope, GlPostLine, GlPostSink};
59use super::inventory_posture::Posture;
60use super::inventory_write_service::{
61 is_dup, legacy_company_echo, money, relay_ambient_scope, InventoryError,
62 InventoryWriteService, NewLandedCost, SubmitOutcome,
63};
64
65/// One target line of the split: the DONE move behind it, its single execution line, and the
66/// basis vectors the three split methods read. The receipt door mints exactly one move per
67/// stock line (one execution line each), so one move = one target line here.
68#[derive(Clone)]
69struct TargetLine {
70 mv: MoveRow,
71 move_line_id: Uuid,
72 /// The move's destination (the warehouse's stock location) — where the debit leg's
73 /// account-resolution chain starts.
74 dest_location_id: Uuid,
75 /// `done_qty` — the move's done quantity (basis of the quantity/weight splits, denominator
76 /// of the remaining-share ratio).
77 done_qty: Decimal,
78 /// The value the move's IN leg blended into its bin (basis of the value split).
79 carried_value: Decimal,
80 /// The item master's per-unit shipping weight (basis of the weight split).
81 weight_per_unit: Decimal,
82 /// How many of the move's units are still on hand (read-only FIFO attribution at validate
83 /// time — the revaluation's effective share).
84 remaining_qty: Decimal,
85}
86
87/// One worksheet row of the computed plan (per cost line × target line).
88struct PlanRow {
89 move_line_id: Uuid,
90 cost_line_id: Uuid,
91 /// This cost line's rounded allocation onto the target line (the last line by
92 /// `move_line_id` eats the rounding diff).
93 share: Decimal,
94 /// The target line's CUMULATIVE allocation across ALL cost lines (repeated on each of the
95 /// line's rows — the worksheet reads cumulative per move line).
96 cumulative: Decimal,
97 remaining_qty: Decimal,
98}
99
100/// The effective revaluation of one target line (Σ over cost lines of the remaining-share
101/// portions of its allocations).
102struct PlanDelta {
103 move_line_id: Uuid,
104 delta: Decimal,
105}
106
107/// The computed validation: everything the write phase and the GL phase need, derived in ONE
108/// read pass so every loud rejection fires BEFORE any row is written.
109struct LcPlan {
110 lc_id: Uuid,
111 lc_number: String,
112 branch_id: Option<Uuid>,
113 currency: String,
114 posting_date: chrono::NaiveDate,
115 target_receipt_id: Uuid,
116 posture: Posture,
117 /// Target lines in WRITE order (sorted by `move_line_id` — the worksheet's insert order,
118 /// which fixes the deterministic rounding-diff recipient).
119 targets: Vec<TargetLine>,
120 rows: Vec<PlanRow>,
121 deltas: Vec<PlanDelta>,
122 /// Debit legs pre-resolved onto the account chain (location override → receipt header
123 /// inventory account), aggregated per account in target order.
124 debits: Vec<(Uuid, Decimal)>,
125 /// Credit legs per cost line (its account, its Σ remaining-share delta), in line order.
126 credits: Vec<(Uuid, Decimal)>,
127 /// Σ cost line amounts (the document total).
128 amount_total: Decimal,
129 /// Σ effective deltas — the value actually revalued onto the bins (what the GL carries).
130 revalued_total: Decimal,
131}
132
133/// Accumulate `amt` onto the `acct` entry, preserving insertion order.
134fn push_amount(legs: &mut Vec<(Uuid, Decimal)>, acct: Uuid, amt: Decimal) {
135 if amt.is_zero() { return }
136 if let Some(e) = legs.iter_mut().find(|(a, _)| *a == acct) {
137 e.1 += amt;
138 } else {
139 legs.push((acct, amt));
140 }
141}
142
143impl InventoryWriteService {
144 // ---- create ---------------------------------------------------------------
145
146 /// Open a DRAFT landed cost: header + cost lines as one unit. Guards: at least one line
147 /// (nothing to allocate); every line carries a credit account (a line without one is
148 /// rejected loudly — its split's credit side would be undefined). Negative amounts are
149 /// allowed: a negative landed cost is the reversal pattern.
150 pub async fn create_landed_cost(&self, lc: NewLandedCost) -> Result<Uuid, InventoryError> {
151 if lc.lines.is_empty() {
152 return Err(InventoryError::LandedCostNoLines { lc_id: Uuid::new_v4() });
153 }
154 for l in &lc.lines {
155 if l.account_id.is_nil() {
156 return Err(InventoryError::LandedCostLineNeedsAccount { line_id: Uuid::new_v4() });
157 }
158 }
159 let id = Uuid::new_v4();
160 let mut tx = self.rpool().begin().await?;
161 relay_ambient_scope(&mut tx).await?;
162 let ins = self.valuation_overlay.insert_landed_cost_draft(&mut tx, &NewLandedCostRow {
163 id,
164 lc_number: &lc.lc_number,
165 branch_id: lc.branch_id,
166 target_receipt_id: lc.target_receipt_id,
167 currency: &lc.currency,
168 posting_date: lc.posting_date,
169 notes: lc.notes.as_deref(),
170 }).await;
171 if let Err(e) = ins {
172 return Err(if is_dup(&e) { InventoryError::DuplicateNumber(lc.lc_number) } else { e.into() });
173 }
174 for l in &lc.lines {
175 // An invalid split method fails the DB's enum cast loudly (a typed mapping would
176 // swallow the offending value); the vocabulary is quantity|value|weight.
177 self.valuation_overlay.insert_lc_line(&mut tx, &NewLcLineRow {
178 id: Uuid::new_v4(),
179 lc_id: id,
180 name: &l.name,
181 account_id: l.account_id,
182 split_method: &l.split_method,
183 amount: l.amount,
184 }).await?;
185 }
186 tx.commit().await?;
187 Ok(id)
188 }
189
190 // ---- validate -------------------------------------------------------------
191
192 /// Validate the DRAFT landed cost: split → revalue → post, the full verb. The physical
193 /// revaluation (worksheet rebuild + value-only SLE rows + bin reblends + `state=done` +
194 /// GL leg armed `pending`) commits FIRST; the GL post is eventually consistent, exactly
195 /// like the voucher doors. Service/job-driven (it needs the composing service's
196 /// `GlPostSink`).
197 pub async fn validate_landed_cost(
198 &self,
199 lc_id: Uuid,
200 sink: &dyn GlPostSink,
201 ) -> Result<SubmitOutcome, InventoryError> {
202 let plan = self.lc_compute(lc_id).await?;
203 self.lc_apply(&plan).await?;
204 self.lc_post(&plan, Some(sink)).await
205 }
206
207 /// The HTTP-shaped validate: the SAME physical revalidation, but the GL leg stays ARMED
208 /// `pending` for a service-driven [`Self::repost_landed_cost`] to drive (the composing
209 /// service's sink is not available on a bare route — the module's standing posture for
210 /// GL-posting verbs). Documents that structurally post nothing (a `periodic` valuation
211 /// policy, an
212 /// all-consumed allocation) retire to `not_applicable` immediately.
213 pub async fn validate_landed_cost_deferred(&self, lc_id: Uuid) -> Result<SubmitOutcome, InventoryError> {
214 let plan = self.lc_compute(lc_id).await?;
215 self.lc_apply(&plan).await?;
216 self.lc_post(&plan, None).await
217 }
218
219 // ---- cancel ---------------------------------------------------------------
220
221 /// Cancel a DRAFT landed cost. A `done` landed cost can never cancel — its bins and ledger
222 /// already revalued; the correction pattern is a NEGATIVE landed cost (swapped legs).
223 pub async fn cancel_landed_cost(&self, lc_id: Uuid) -> Result<(), InventoryError> {
224 let mut tx = self.rpool().begin().await?;
225 relay_ambient_scope(&mut tx).await?;
226 let hdr = self.valuation_overlay
227 .fetch_lc_header(&mut tx, lc_id).await?
228 .ok_or(InventoryError::NotFound(lc_id))?;
229 if hdr.state != "draft" {
230 return Err(InventoryError::LandedCostNotDraft { lc_id, state: hdr.state });
231 }
232 self.valuation_overlay.delete_worksheet(&mut tx, lc_id).await?;
233 self.valuation_overlay.mark_lc_cancelled(&mut tx, lc_id).await?;
234 tx.commit().await?;
235 Ok(())
236 }
237
238 // ---- GL repost: re-drive a stuck post --------------------------------------
239
240 /// Re-drive the GL leg of a DONE landed cost whose `posting_state` is `pending` or
241 /// `failed` (a rejected or interrupted post — the physical revaluation already committed
242 /// and is never re-touched). The envelope is rebuilt from the MINTED ledger and the
243 /// worksheet: debit legs from the landed-cost SLE rows (each row's signed delta, its
244 /// target line resolving the valuation-account chain), credit legs from the worksheet's
245 /// per-cost-line shares scaled by each row's snapshotted remaining share (a done move's
246 /// quantity is frozen, so the rebuild is exact). Idempotent: `posted` short-circuits with
247 /// the recorded ids, `not_applicable` is a no-op, and the envelope re-uses the document's
248 /// source identity so accounting's dedupe on `(company, source_type, source_id,
249 /// posting_type)` can never double-post.
250 pub async fn repost_landed_cost(
251 &self,
252 lc_id: Uuid,
253 sink: &dyn GlPostSink,
254 ) -> Result<SubmitOutcome, InventoryError> {
255 let (hdr, receipt_inventory_account_id, lines, worksheet, sles) = {
256 let mut conn = self.rpool().acquire().await?;
257 // Re-bind the caller's ambient org scope before any read (ADR-0029) — the scope is
258 // task-local and a fresh pool connection carries none of it; undecorated (module
259 // tests, jobs) the reads stay plain.
260 relay_ambient_scope(&mut conn).await?;
261 let hdr = self.valuation_overlay
262 .fetch_lc_header(&mut conn, lc_id).await?
263 .ok_or(InventoryError::NotFound(lc_id))?;
264 let target = self.valuation_overlay
265 .fetch_lc_target_receipt(&mut conn, hdr.target_receipt_id).await?
266 .ok_or(InventoryError::NotFound(hdr.target_receipt_id))?;
267 let lines = self.valuation_overlay.fetch_lc_lines(&mut conn, lc_id).await?;
268 let worksheet = self.valuation_overlay.fetch_worksheet(&mut conn, lc_id).await?;
269 let sles = self.sles.fetch_lc_revaluations(&mut conn, lc_id).await?;
270 (hdr, target.inventory_account_id, lines, worksheet, sles)
271 };
272 match hdr.posting_state.as_str() {
273 "posted" => return Ok(SubmitOutcome {
274 voucher_id: lc_id, posted: true,
275 journal_id: hdr.journal_id, post_id: hdr.accounting_post_id,
276 gl_amount: Decimal::ZERO,
277 }),
278 "not_applicable" => return Ok(SubmitOutcome {
279 voucher_id: lc_id, posted: false, journal_id: None, post_id: None,
280 gl_amount: Decimal::ZERO,
281 }),
282 _ => {} // pending | failed → re-drive below
283 }
284 if hdr.state != "done" {
285 // posting_state only leaves not_applicable inside a validate transaction, so this
286 // is a defensive guard — but a hand-mangled row should fail loudly, not post.
287 return Err(InventoryError::LandedCostNotDraft { lc_id, state: hdr.state });
288 }
289
290 // Debit legs: one per minted SLE row, resolved onto the account chain
291 // (target line's location override → the receipt header's inventory account).
292 let mut debits: Vec<(Uuid, Decimal)> = Vec::new();
293 let mut credits: Vec<(Uuid, Decimal)> = Vec::new();
294 {
295 let mut conn = self.rpool().acquire().await?;
296 for (voucher_no, delta) in &sles {
297 // voucher_no = {lc_number}/{move_name}/{move_line_id} — the trailing id is the
298 // stable target grain.
299 let line_id = voucher_no.rsplit('/').next()
300 .and_then(|s| Uuid::parse_str(s).ok())
301 .ok_or(InventoryError::LandedCostNoValuationAccount { move_id: lc_id })?;
302 let line = self.move_lines.fetch_line(&mut conn, line_id).await?
303 .ok_or(InventoryError::LandedCostNoValuationAccount { move_id: lc_id })?;
304 let acct = self.inventory_leg_account(line.location_dest_id, receipt_inventory_account_id).await?;
305 push_amount(&mut debits, acct, *delta);
306 }
307 // Credit legs: per cost line, its worksheet shares scaled by the snapshotted
308 // remaining share over the (frozen) done qty — the exact validate-time delta.
309 for w in &worksheet {
310 let Some(line) = lines.iter().find(|l| l.id == w.cost_line_id) else { continue };
311 let target_line = self.move_lines.fetch_line(&mut conn, w.move_line_id).await?
312 .ok_or(InventoryError::LandedCostNoValuationAccount { move_id: lc_id })?;
313 if target_line.quantity.is_zero() { continue }
314 let delta = money(w.share * w.remaining_qty / target_line.quantity);
315 push_amount(&mut credits, line.account_id, delta);
316 }
317 }
318 let revalued_total: Decimal = sles.iter().map(|(_, d)| *d).sum();
319 let env = Self::lc_envelope(
320 lc_id, &hdr.lc_number, hdr.branch_id, hdr.posting_date, &hdr.currency,
321 &debits, &credits, revalued_total,
322 )?;
323 self.emit_and_reconcile(GlVoucher::LandedCost, lc_id, &env, sink, revalued_total).await
324 }
325
326 // ---- the shared compute (read phase + split math; rejects BEFORE any write) ----
327
328 /// Resolve the draft, its cost lines, the target receipt's DONE moves and every basis
329 /// vector, run the split math, and return the full plan. Every loud guard fires here — a
330 /// rejected validation has written nothing at all.
331 async fn lc_compute(&self, lc_id: Uuid) -> Result<LcPlan, InventoryError> {
332 let (hdr, lines, mut targets, posture, receipt_inventory_account_id) = {
333 let mut conn = self.rpool().acquire().await?;
334 // Re-bind the caller's ambient org scope before any read (ADR-0029) — the scope is
335 // task-local and a fresh pool connection carries none of it; undecorated (module
336 // tests, jobs) the reads stay plain.
337 relay_ambient_scope(&mut conn).await?;
338 let hdr = self.valuation_overlay
339 .fetch_lc_header(&mut conn, lc_id).await?
340 .ok_or(InventoryError::NotFound(lc_id))?;
341 if hdr.state != "draft" {
342 return Err(InventoryError::LandedCostNotDraft { lc_id, state: hdr.state });
343 }
344 let target = self.valuation_overlay
345 .fetch_lc_target_receipt(&mut conn, hdr.target_receipt_id).await?
346 .ok_or(InventoryError::NotFound(hdr.target_receipt_id))?;
347 let lines = self.valuation_overlay.fetch_lc_lines(&mut conn, lc_id).await?;
348 let posture = self.posting_posture_on(&mut conn).await?;
349
350 // The target lines: the receipt's DONE moves (the door stamps the receipt number
351 // as every line move's `origin`), each with its single execution line.
352 let mut targets: Vec<TargetLine> = Vec::new();
353 let moves = self.moves.fetch_moves_by_origin(&mut conn, &target.receipt_number).await?;
354 for mv in moves {
355 if mv.state != "done" || mv.quantity <= Decimal::ZERO { continue }
356 let Some(line) = self.move_lines.fetch_lines_for_move(&mut conn, mv.id).await?
357 .into_iter().find(|l| l.quantity > Decimal::ZERO) else { continue };
358 let carried = self.sles.sum_move_in_value(&mut conn, mv.id).await?;
359 let weight = self.valuation_overlay
360 .fetch_item_weight(&mut conn, mv.item_id).await?;
361 targets.push(TargetLine {
362 move_line_id: line.id,
363 dest_location_id: mv.location_dest_id,
364 done_qty: mv.quantity,
365 carried_value: carried,
366 weight_per_unit: weight,
367 remaining_qty: Decimal::ZERO, // filled by the attribution below
368 mv,
369 });
370 }
371 (hdr, lines, targets, posture, target.inventory_account_id)
372 };
373 if targets.is_empty() {
374 // Nothing DONE to revalue: the receipt was never submitted, or every line was
375 // zero / a landed-cost service line (the seam — those mint no stock).
376 return Err(InventoryError::LandedCostNoValuedTargets { receipt_id: hdr.target_receipt_id });
377 }
378 if lines.is_empty() {
379 return Err(InventoryError::LandedCostNoLines { lc_id });
380 }
381 for l in &lines {
382 if l.account_id.is_nil() {
383 return Err(InventoryError::LandedCostLineNeedsAccount { line_id: l.id });
384 }
385 }
386 // A `standard` cost method refuses loudly: under standard costing a landed cost would
387 // introduce a variance the standard-recompute engine (a later increment) must absorb —
388 // refusing beats silently diverging. `average` (and the `fifo` vocabulary) pass.
389 if posture.cost_method == "standard" {
390 return Err(InventoryError::LandedCostRequiresCostMethod {
391 cost_method: posture.cost_method.clone(),
392 });
393 }
394
395 // Read-only FIFO attribution (the remaining share per move) — attribution, NOT costing.
396 let move_ids: Vec<Uuid> = targets.iter().map(|t| t.mv.id).collect();
397 let remaining = self.sles.remaining_qty_for_moves(&self.rpool(), &move_ids).await?;
398 for t in targets.iter_mut() {
399 t.remaining_qty = remaining.get(&t.mv.id).copied().unwrap_or(Decimal::ZERO);
400 }
401
402 // Deterministic write order: by `move_line_id` (the worksheet's insert order — the
403 // LAST line by move_line_id is the rounding-diff recipient), then cost-line order.
404 targets.sort_by(|a, b| a.move_line_id.cmp(&b.move_line_id));
405
406 // ---- the split math (one pass per cost line) --------------------------------------
407 let mut rows: Vec<PlanRow> = Vec::new();
408 let mut delta_by_line: HashMap<Uuid, Decimal> = HashMap::new();
409 let mut credits: Vec<(Uuid, Decimal)> = Vec::new();
410 for c in &lines {
411 let basis: Vec<Decimal> = targets.iter().map(|t| match c.split_method.as_str() {
412 "value" => t.carried_value,
413 "weight" => t.done_qty * t.weight_per_unit,
414 _ => t.done_qty, // quantity
415 }).collect();
416 let denominator: Decimal = basis.iter().sum();
417 // The LOUD zero-denominator rejection — the decided deviation: no equal-split
418 // fallback, no partial worksheet rows. An all-zero weight basis (no item carries
419 // a per-unit weight) or an all-zero value basis (zero-valued target lines) is a
420 // configuration defect the operator must see.
421 if denominator.is_zero() {
422 return Err(InventoryError::LandedCostZeroSplitBasis {
423 basis: c.split_method.clone(),
424 target_receipt_id: hdr.target_receipt_id,
425 });
426 }
427 let mut allocated = Decimal::ZERO;
428 let mut line_delta_total = Decimal::ZERO;
429 for (i, t) in targets.iter().enumerate() {
430 let share = if i == targets.len() - 1 {
431 // The last line (by move_line_id) eats the rounding diff — the worksheet's
432 // insert order makes this recipient deterministic.
433 c.amount - allocated
434 } else {
435 let s = money(c.amount * basis[i] / denominator);
436 allocated += s;
437 s
438 };
439 // THE RETROACTIVE-REVALUATION ASYMMETRY: only the remaining share of the
440 // allocation revalues the bin; the consumed portion produces NO correcting
441 // entry (no COGS true-up, no journal leg). Deliberate — see the engine verb.
442 let delta = if t.done_qty.is_zero() {
443 Decimal::ZERO
444 } else {
445 money(share * t.remaining_qty / t.done_qty)
446 };
447 line_delta_total += delta;
448 *delta_by_line.entry(t.move_line_id).or_insert(Decimal::ZERO) += delta;
449 rows.push(PlanRow {
450 move_line_id: t.move_line_id,
451 cost_line_id: c.id,
452 share,
453 cumulative: Decimal::ZERO, // filled after every cost line has run
454 remaining_qty: t.remaining_qty,
455 });
456 }
457 // G2 check (a): the worksheet's Σ allocations must equal the Σ cost amounts (the
458 // last-line-eats-diff rule makes the identity exact; a violation is internal).
459 let line_shares: Decimal = rows.iter()
460 .filter(|r| r.cost_line_id == c.id).map(|r| r.share).sum();
461 if line_shares != c.amount {
462 return Err(InventoryError::LandedCostAllocationMismatch {
463 lc_id, allocated: line_shares, declared: c.amount,
464 });
465 }
466 push_amount(&mut credits, c.account_id, line_delta_total);
467 }
468 // The cumulative per move line (across ALL cost lines), stamped on each of its rows.
469 let mut cumulative_by_line: HashMap<Uuid, Decimal> = HashMap::new();
470 for r in &rows {
471 *cumulative_by_line.entry(r.move_line_id).or_insert(Decimal::ZERO) += r.share;
472 }
473 for r in rows.iter_mut() {
474 r.cumulative = cumulative_by_line[&r.move_line_id];
475 }
476
477 let deltas: Vec<PlanDelta> = targets.iter().map(|t| PlanDelta {
478 move_line_id: t.move_line_id,
479 delta: delta_by_line.get(&t.move_line_id).copied().unwrap_or(Decimal::ZERO),
480 }).collect();
481 let amount_total: Decimal = lines.iter().map(|c| c.amount).sum();
482 let revalued_total: Decimal = delta_by_line.values().copied().sum();
483
484 // Debit legs pre-resolved onto the account chain: the target line's destination
485 // location override, else the receipt header's inventory account.
486 let mut debits: Vec<(Uuid, Decimal)> = Vec::new();
487 for t in &targets {
488 let Some(d) = deltas.iter().find(|d| d.move_line_id == t.move_line_id) else { continue };
489 if d.delta.is_zero() { continue }
490 let acct = self.inventory_leg_account(t.dest_location_id, receipt_inventory_account_id).await?;
491 push_amount(&mut debits, acct, d.delta);
492 }
493
494 Ok(LcPlan {
495 lc_id,
496 lc_number: hdr.lc_number,
497 branch_id: hdr.branch_id,
498 currency: hdr.currency,
499 posting_date: hdr.posting_date,
500 target_receipt_id: hdr.target_receipt_id,
501 posture,
502 targets,
503 rows,
504 deltas,
505 debits,
506 credits,
507 amount_total,
508 revalued_total,
509 })
510 }
511
512 // ---- the write phase (one transaction: worksheet + revaluation + state) ----------
513
514 /// Apply a computed plan in ONE transaction: rebuild the worksheet (delete + recreate,
515 /// ordered by `move_line_id`), mint the value-only revaluation SLE rows + bin reblends
516 /// through the engine verb, flip the document `done` and arm its GL leg `pending`. A crash
517 /// before commit rolls the whole unit back; a crash after it leaves the armed leg for
518 /// [`Self::repost_landed_cost`].
519 async fn lc_apply(&self, plan: &LcPlan) -> Result<(), InventoryError> {
520 let mut tx = self.rpool().begin().await?;
521 relay_ambient_scope(&mut tx).await?;
522 self.valuation_overlay.delete_worksheet(&mut tx, plan.lc_id).await?;
523 for r in &plan.rows {
524 self.valuation_overlay.insert_worksheet_row(&mut tx, &NewWorksheetRow {
525 lc_id: plan.lc_id,
526 move_line_id: r.move_line_id,
527 cost_line_id: r.cost_line_id,
528 share: r.share,
529 additional_landed_cost: r.cumulative,
530 remaining_qty: r.remaining_qty,
531 }).await?;
532 }
533 for t in &plan.targets {
534 let Some(d) = plan.deltas.iter().find(|d| d.move_line_id == t.move_line_id) else { continue };
535 // The engine owns the SLE/bin writes — the door calls the verb (one-writer rule).
536 // A zero delta mints nothing (an all-consumed target line revalues nothing).
537 self.adjust_move_value(&mut tx, &t.mv, t.move_line_id, d.delta, plan.lc_id, &plan.lc_number).await?;
538 }
539 self.valuation_overlay.mark_lc_validated(&mut tx, plan.lc_id, plan.amount_total).await?;
540 tx.commit().await?;
541 Ok(())
542 }
543
544 // ---- the GL phase (shared by both validate entrypoints) -------------------------
545
546 /// `Some(sink)` emits the envelope and reconciles; `None` (the deferred/HTTP validate)
547 /// leaves the armed `pending` leg for a repost drive. Documents that structurally post
548 /// nothing — a `periodic` valuation policy, an all-consumed allocation (Σ remaining-share
549 /// = 0) — retire to `not_applicable` either way.
550 async fn lc_post(
551 &self,
552 plan: &LcPlan,
553 sink: Option<&dyn GlPostSink>,
554 ) -> Result<SubmitOutcome, InventoryError> {
555 let event = || InventoryEvent::LandedCostValidated(LandedCostValidated {
556 lc_id: plan.lc_id,
557 // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
558 // consumers that still read a tenant off the wire. No module statement keys on it.
559 company_id: legacy_company_echo(),
560 target_receipt_id: plan.target_receipt_id,
561 amount_total: plan.amount_total,
562 revalued_value: plan.revalued_total,
563 });
564 if plan.posture.periodic || plan.revalued_total.is_zero() {
565 self.gl.mark_not_applicable(&self.rpool(), GlVoucher::LandedCost, plan.lc_id).await?;
566 self.sink.publish(event());
567 return Ok(SubmitOutcome {
568 voucher_id: plan.lc_id, posted: false, journal_id: None, post_id: None,
569 gl_amount: Decimal::ZERO,
570 });
571 }
572 let env = Self::lc_envelope(
573 plan.lc_id, &plan.lc_number, plan.branch_id, plan.posting_date,
574 &plan.currency, &plan.debits, &plan.credits, plan.revalued_total,
575 )?;
576 match sink {
577 None => {
578 // Deferred validate: the leg stays armed `pending`; the event still fires —
579 // the physical revaluation happened.
580 self.sink.publish(event());
581 Ok(SubmitOutcome {
582 voucher_id: plan.lc_id, posted: false, journal_id: None, post_id: None,
583 gl_amount: plan.revalued_total,
584 })
585 }
586 Some(sink) => {
587 let out = self.emit_and_reconcile(
588 GlVoucher::LandedCost, plan.lc_id, &env, sink, plan.revalued_total,
589 ).await?;
590 self.sink.publish(event());
591 Ok(out)
592 }
593 }
594 }
595
596 /// Build the balanced landed-cost envelope. Debit legs are the target lines' revaluation
597 /// deltas on the account-resolution chain (location override → receipt header inventory
598 /// account); credit legs carry each cost line's remaining-share total — BOTH sides carry
599 /// only the remaining-portion value, so the post balances without inventing the missing
600 /// COGS leg the consumed portion would need. A NEGATIVE document (the reversal pattern)
601 /// swaps the sides verbatim: absolute amounts on the opposite legs. G2 check (b): the
602 /// journal's Σ must equal Σ deltas — by construction both sides sum `|total|`; the assert
603 /// keeps a future edit from silently unbalancing it.
604 fn lc_envelope(
605 lc_id: Uuid,
606 lc_number: &str,
607 branch_id: Option<Uuid>,
608 posting_date: chrono::NaiveDate,
609 currency: &str,
610 debits: &[(Uuid, Decimal)],
611 credits: &[(Uuid, Decimal)],
612 total: Decimal,
613 ) -> Result<AccountingPostEnvelope, InventoryError> {
614 let (dr_side, cr_side) = if total >= Decimal::ZERO {
615 (debits, credits)
616 } else {
617 // Reversal: debit the cost-line accounts, credit inventory — swapped verbatim.
618 (credits, debits)
619 };
620 let abs = |v: Decimal| v.abs();
621 let lines: Vec<GlPostLine> = dr_side.iter()
622 .map(|(a, amt)| GlPostLine::debit(*a, abs(*amt)).with_description("Inventory valuation"))
623 .chain(cr_side.iter()
624 .map(|(a, amt)| GlPostLine::credit(*a, abs(*amt)).with_description("Landed cost")))
625 .collect();
626 let env = AccountingPostEnvelope {
627 idempotency_key: lc_id.to_string(),
628 // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
629 // consumers that still read a tenant off the wire. No module statement keys on it.
630 company_id: legacy_company_echo(),
631 branch_id,
632 source_type: "inventory".into(),
633 source_id: lc_id,
634 source_reference: Some(lc_number.to_string()),
635 posting_date,
636 currency: currency.to_string(),
637 posting_type: "original".into(),
638 reverses_post_id: None,
639 description: Some("Landed cost".into()),
640 lines,
641 };
642 // G2 (b): the journal must carry exactly the revalued value, on both sides.
643 let dr_sum: Decimal = env.lines.iter().map(|l| l.debit).sum();
644 let cr_sum: Decimal = env.lines.iter().map(|l| l.credit).sum();
645 if dr_sum != abs(total) || cr_sum != abs(total) {
646 return Err(InventoryError::LandedCostAllocationMismatch {
647 lc_id, allocated: dr_sum, declared: abs(total),
648 });
649 }
650 Ok(env)
651 }
652}
653
654/// Exposed for tests: the split math's HALF-UP rounding helper is the service-wide `money`.
655#[allow(dead_code)]
656fn _money_is_half_up(v: Decimal) -> Decimal { money(v) }