Skip to main content

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) }