use rust_decimal::Decimal;
use std::collections::HashMap;
use uuid::Uuid;
use crate::infrastructure::persistence::{
GlVoucher, LcLineRow, MoveRow, NewLcLineRow, NewLandedCostRow, NewWorksheetRow,
};
use super::inventory_events::{InventoryEvent, LandedCostValidated};
use super::inventory_gl::{AccountingPostEnvelope, GlPostLine, GlPostSink};
use super::inventory_posture::Posture;
use super::inventory_write_service::{
is_dup, legacy_company_echo, money, relay_ambient_scope, InventoryError,
InventoryWriteService, NewLandedCost, SubmitOutcome,
};
#[derive(Clone)]
struct TargetLine {
mv: MoveRow,
move_line_id: Uuid,
dest_location_id: Uuid,
done_qty: Decimal,
carried_value: Decimal,
weight_per_unit: Decimal,
remaining_qty: Decimal,
}
struct PlanRow {
move_line_id: Uuid,
cost_line_id: Uuid,
share: Decimal,
cumulative: Decimal,
remaining_qty: Decimal,
}
struct PlanDelta {
move_line_id: Uuid,
delta: Decimal,
}
struct LcPlan {
lc_id: Uuid,
lc_number: String,
branch_id: Option<Uuid>,
currency: String,
posting_date: chrono::NaiveDate,
target_receipt_id: Uuid,
posture: Posture,
targets: Vec<TargetLine>,
rows: Vec<PlanRow>,
deltas: Vec<PlanDelta>,
debits: Vec<(Uuid, Decimal)>,
credits: Vec<(Uuid, Decimal)>,
amount_total: Decimal,
revalued_total: Decimal,
}
fn push_amount(legs: &mut Vec<(Uuid, Decimal)>, acct: Uuid, amt: Decimal) {
if amt.is_zero() { return }
if let Some(e) = legs.iter_mut().find(|(a, _)| *a == acct) {
e.1 += amt;
} else {
legs.push((acct, amt));
}
}
impl InventoryWriteService {
pub async fn create_landed_cost(&self, lc: NewLandedCost) -> Result<Uuid, InventoryError> {
if lc.lines.is_empty() {
return Err(InventoryError::LandedCostNoLines { lc_id: Uuid::new_v4() });
}
for l in &lc.lines {
if l.account_id.is_nil() {
return Err(InventoryError::LandedCostLineNeedsAccount { line_id: Uuid::new_v4() });
}
}
let id = Uuid::new_v4();
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let ins = self.valuation_overlay.insert_landed_cost_draft(&mut tx, &NewLandedCostRow {
id,
lc_number: &lc.lc_number,
branch_id: lc.branch_id,
target_receipt_id: lc.target_receipt_id,
currency: &lc.currency,
posting_date: lc.posting_date,
notes: lc.notes.as_deref(),
}).await;
if let Err(e) = ins {
return Err(if is_dup(&e) { InventoryError::DuplicateNumber(lc.lc_number) } else { e.into() });
}
for l in &lc.lines {
self.valuation_overlay.insert_lc_line(&mut tx, &NewLcLineRow {
id: Uuid::new_v4(),
lc_id: id,
name: &l.name,
account_id: l.account_id,
split_method: &l.split_method,
amount: l.amount,
}).await?;
}
tx.commit().await?;
Ok(id)
}
pub async fn validate_landed_cost(
&self,
lc_id: Uuid,
sink: &dyn GlPostSink,
) -> Result<SubmitOutcome, InventoryError> {
let plan = self.lc_compute(lc_id).await?;
self.lc_apply(&plan).await?;
self.lc_post(&plan, Some(sink)).await
}
pub async fn validate_landed_cost_deferred(&self, lc_id: Uuid) -> Result<SubmitOutcome, InventoryError> {
let plan = self.lc_compute(lc_id).await?;
self.lc_apply(&plan).await?;
self.lc_post(&plan, None).await
}
pub async fn cancel_landed_cost(&self, lc_id: Uuid) -> Result<(), InventoryError> {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let hdr = self.valuation_overlay
.fetch_lc_header(&mut tx, lc_id).await?
.ok_or(InventoryError::NotFound(lc_id))?;
if hdr.state != "draft" {
return Err(InventoryError::LandedCostNotDraft { lc_id, state: hdr.state });
}
self.valuation_overlay.delete_worksheet(&mut tx, lc_id).await?;
self.valuation_overlay.mark_lc_cancelled(&mut tx, lc_id).await?;
tx.commit().await?;
Ok(())
}
pub async fn repost_landed_cost(
&self,
lc_id: Uuid,
sink: &dyn GlPostSink,
) -> Result<SubmitOutcome, InventoryError> {
let (hdr, receipt_inventory_account_id, lines, worksheet, sles) = {
let mut conn = self.rpool().acquire().await?;
relay_ambient_scope(&mut conn).await?;
let hdr = self.valuation_overlay
.fetch_lc_header(&mut conn, lc_id).await?
.ok_or(InventoryError::NotFound(lc_id))?;
let target = self.valuation_overlay
.fetch_lc_target_receipt(&mut conn, hdr.target_receipt_id).await?
.ok_or(InventoryError::NotFound(hdr.target_receipt_id))?;
let lines = self.valuation_overlay.fetch_lc_lines(&mut conn, lc_id).await?;
let worksheet = self.valuation_overlay.fetch_worksheet(&mut conn, lc_id).await?;
let sles = self.sles.fetch_lc_revaluations(&mut conn, lc_id).await?;
(hdr, target.inventory_account_id, lines, worksheet, sles)
};
match hdr.posting_state.as_str() {
"posted" => return Ok(SubmitOutcome {
voucher_id: lc_id, posted: true,
journal_id: hdr.journal_id, post_id: hdr.accounting_post_id,
gl_amount: Decimal::ZERO,
}),
"not_applicable" => return Ok(SubmitOutcome {
voucher_id: lc_id, posted: false, journal_id: None, post_id: None,
gl_amount: Decimal::ZERO,
}),
_ => {} }
if hdr.state != "done" {
return Err(InventoryError::LandedCostNotDraft { lc_id, state: hdr.state });
}
let mut debits: Vec<(Uuid, Decimal)> = Vec::new();
let mut credits: Vec<(Uuid, Decimal)> = Vec::new();
{
let mut conn = self.rpool().acquire().await?;
for (voucher_no, delta) in &sles {
let line_id = voucher_no.rsplit('/').next()
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or(InventoryError::LandedCostNoValuationAccount { move_id: lc_id })?;
let line = self.move_lines.fetch_line(&mut conn, line_id).await?
.ok_or(InventoryError::LandedCostNoValuationAccount { move_id: lc_id })?;
let acct = self.inventory_leg_account(line.location_dest_id, receipt_inventory_account_id).await?;
push_amount(&mut debits, acct, *delta);
}
for w in &worksheet {
let Some(line) = lines.iter().find(|l| l.id == w.cost_line_id) else { continue };
let target_line = self.move_lines.fetch_line(&mut conn, w.move_line_id).await?
.ok_or(InventoryError::LandedCostNoValuationAccount { move_id: lc_id })?;
if target_line.quantity.is_zero() { continue }
let delta = money(w.share * w.remaining_qty / target_line.quantity);
push_amount(&mut credits, line.account_id, delta);
}
}
let revalued_total: Decimal = sles.iter().map(|(_, d)| *d).sum();
let env = Self::lc_envelope(
lc_id, &hdr.lc_number, hdr.branch_id, hdr.posting_date, &hdr.currency,
&debits, &credits, revalued_total,
)?;
self.emit_and_reconcile(GlVoucher::LandedCost, lc_id, &env, sink, revalued_total).await
}
async fn lc_compute(&self, lc_id: Uuid) -> Result<LcPlan, InventoryError> {
let (hdr, lines, mut targets, posture, receipt_inventory_account_id) = {
let mut conn = self.rpool().acquire().await?;
relay_ambient_scope(&mut conn).await?;
let hdr = self.valuation_overlay
.fetch_lc_header(&mut conn, lc_id).await?
.ok_or(InventoryError::NotFound(lc_id))?;
if hdr.state != "draft" {
return Err(InventoryError::LandedCostNotDraft { lc_id, state: hdr.state });
}
let target = self.valuation_overlay
.fetch_lc_target_receipt(&mut conn, hdr.target_receipt_id).await?
.ok_or(InventoryError::NotFound(hdr.target_receipt_id))?;
let lines = self.valuation_overlay.fetch_lc_lines(&mut conn, lc_id).await?;
let posture = self.posting_posture_on(&mut conn).await?;
let mut targets: Vec<TargetLine> = Vec::new();
let moves = self.moves.fetch_moves_by_origin(&mut conn, &target.receipt_number).await?;
for mv in moves {
if mv.state != "done" || mv.quantity <= Decimal::ZERO { continue }
let Some(line) = self.move_lines.fetch_lines_for_move(&mut conn, mv.id).await?
.into_iter().find(|l| l.quantity > Decimal::ZERO) else { continue };
let carried = self.sles.sum_move_in_value(&mut conn, mv.id).await?;
let weight = self.valuation_overlay
.fetch_item_weight(&mut conn, mv.item_id).await?;
targets.push(TargetLine {
move_line_id: line.id,
dest_location_id: mv.location_dest_id,
done_qty: mv.quantity,
carried_value: carried,
weight_per_unit: weight,
remaining_qty: Decimal::ZERO, mv,
});
}
(hdr, lines, targets, posture, target.inventory_account_id)
};
if targets.is_empty() {
return Err(InventoryError::LandedCostNoValuedTargets { receipt_id: hdr.target_receipt_id });
}
if lines.is_empty() {
return Err(InventoryError::LandedCostNoLines { lc_id });
}
for l in &lines {
if l.account_id.is_nil() {
return Err(InventoryError::LandedCostLineNeedsAccount { line_id: l.id });
}
}
if posture.cost_method == "standard" {
return Err(InventoryError::LandedCostRequiresCostMethod {
cost_method: posture.cost_method.clone(),
});
}
let move_ids: Vec<Uuid> = targets.iter().map(|t| t.mv.id).collect();
let remaining = self.sles.remaining_qty_for_moves(&self.rpool(), &move_ids).await?;
for t in targets.iter_mut() {
t.remaining_qty = remaining.get(&t.mv.id).copied().unwrap_or(Decimal::ZERO);
}
targets.sort_by(|a, b| a.move_line_id.cmp(&b.move_line_id));
let mut rows: Vec<PlanRow> = Vec::new();
let mut delta_by_line: HashMap<Uuid, Decimal> = HashMap::new();
let mut credits: Vec<(Uuid, Decimal)> = Vec::new();
for c in &lines {
let basis: Vec<Decimal> = targets.iter().map(|t| match c.split_method.as_str() {
"value" => t.carried_value,
"weight" => t.done_qty * t.weight_per_unit,
_ => t.done_qty, }).collect();
let denominator: Decimal = basis.iter().sum();
if denominator.is_zero() {
return Err(InventoryError::LandedCostZeroSplitBasis {
basis: c.split_method.clone(),
target_receipt_id: hdr.target_receipt_id,
});
}
let mut allocated = Decimal::ZERO;
let mut line_delta_total = Decimal::ZERO;
for (i, t) in targets.iter().enumerate() {
let share = if i == targets.len() - 1 {
c.amount - allocated
} else {
let s = money(c.amount * basis[i] / denominator);
allocated += s;
s
};
let delta = if t.done_qty.is_zero() {
Decimal::ZERO
} else {
money(share * t.remaining_qty / t.done_qty)
};
line_delta_total += delta;
*delta_by_line.entry(t.move_line_id).or_insert(Decimal::ZERO) += delta;
rows.push(PlanRow {
move_line_id: t.move_line_id,
cost_line_id: c.id,
share,
cumulative: Decimal::ZERO, remaining_qty: t.remaining_qty,
});
}
let line_shares: Decimal = rows.iter()
.filter(|r| r.cost_line_id == c.id).map(|r| r.share).sum();
if line_shares != c.amount {
return Err(InventoryError::LandedCostAllocationMismatch {
lc_id, allocated: line_shares, declared: c.amount,
});
}
push_amount(&mut credits, c.account_id, line_delta_total);
}
let mut cumulative_by_line: HashMap<Uuid, Decimal> = HashMap::new();
for r in &rows {
*cumulative_by_line.entry(r.move_line_id).or_insert(Decimal::ZERO) += r.share;
}
for r in rows.iter_mut() {
r.cumulative = cumulative_by_line[&r.move_line_id];
}
let deltas: Vec<PlanDelta> = targets.iter().map(|t| PlanDelta {
move_line_id: t.move_line_id,
delta: delta_by_line.get(&t.move_line_id).copied().unwrap_or(Decimal::ZERO),
}).collect();
let amount_total: Decimal = lines.iter().map(|c| c.amount).sum();
let revalued_total: Decimal = delta_by_line.values().copied().sum();
let mut debits: Vec<(Uuid, Decimal)> = Vec::new();
for t in &targets {
let Some(d) = deltas.iter().find(|d| d.move_line_id == t.move_line_id) else { continue };
if d.delta.is_zero() { continue }
let acct = self.inventory_leg_account(t.dest_location_id, receipt_inventory_account_id).await?;
push_amount(&mut debits, acct, d.delta);
}
Ok(LcPlan {
lc_id,
lc_number: hdr.lc_number,
branch_id: hdr.branch_id,
currency: hdr.currency,
posting_date: hdr.posting_date,
target_receipt_id: hdr.target_receipt_id,
posture,
targets,
rows,
deltas,
debits,
credits,
amount_total,
revalued_total,
})
}
async fn lc_apply(&self, plan: &LcPlan) -> Result<(), InventoryError> {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
self.valuation_overlay.delete_worksheet(&mut tx, plan.lc_id).await?;
for r in &plan.rows {
self.valuation_overlay.insert_worksheet_row(&mut tx, &NewWorksheetRow {
lc_id: plan.lc_id,
move_line_id: r.move_line_id,
cost_line_id: r.cost_line_id,
share: r.share,
additional_landed_cost: r.cumulative,
remaining_qty: r.remaining_qty,
}).await?;
}
for t in &plan.targets {
let Some(d) = plan.deltas.iter().find(|d| d.move_line_id == t.move_line_id) else { continue };
self.adjust_move_value(&mut tx, &t.mv, t.move_line_id, d.delta, plan.lc_id, &plan.lc_number).await?;
}
self.valuation_overlay.mark_lc_validated(&mut tx, plan.lc_id, plan.amount_total).await?;
tx.commit().await?;
Ok(())
}
async fn lc_post(
&self,
plan: &LcPlan,
sink: Option<&dyn GlPostSink>,
) -> Result<SubmitOutcome, InventoryError> {
let event = || InventoryEvent::LandedCostValidated(LandedCostValidated {
lc_id: plan.lc_id,
company_id: legacy_company_echo(),
target_receipt_id: plan.target_receipt_id,
amount_total: plan.amount_total,
revalued_value: plan.revalued_total,
});
if plan.posture.periodic || plan.revalued_total.is_zero() {
self.gl.mark_not_applicable(&self.rpool(), GlVoucher::LandedCost, plan.lc_id).await?;
self.sink.publish(event());
return Ok(SubmitOutcome {
voucher_id: plan.lc_id, posted: false, journal_id: None, post_id: None,
gl_amount: Decimal::ZERO,
});
}
let env = Self::lc_envelope(
plan.lc_id, &plan.lc_number, plan.branch_id, plan.posting_date,
&plan.currency, &plan.debits, &plan.credits, plan.revalued_total,
)?;
match sink {
None => {
self.sink.publish(event());
Ok(SubmitOutcome {
voucher_id: plan.lc_id, posted: false, journal_id: None, post_id: None,
gl_amount: plan.revalued_total,
})
}
Some(sink) => {
let out = self.emit_and_reconcile(
GlVoucher::LandedCost, plan.lc_id, &env, sink, plan.revalued_total,
).await?;
self.sink.publish(event());
Ok(out)
}
}
}
fn lc_envelope(
lc_id: Uuid,
lc_number: &str,
branch_id: Option<Uuid>,
posting_date: chrono::NaiveDate,
currency: &str,
debits: &[(Uuid, Decimal)],
credits: &[(Uuid, Decimal)],
total: Decimal,
) -> Result<AccountingPostEnvelope, InventoryError> {
let (dr_side, cr_side) = if total >= Decimal::ZERO {
(debits, credits)
} else {
(credits, debits)
};
let abs = |v: Decimal| v.abs();
let lines: Vec<GlPostLine> = dr_side.iter()
.map(|(a, amt)| GlPostLine::debit(*a, abs(*amt)).with_description("Inventory valuation"))
.chain(cr_side.iter()
.map(|(a, amt)| GlPostLine::credit(*a, abs(*amt)).with_description("Landed cost")))
.collect();
let env = AccountingPostEnvelope {
idempotency_key: lc_id.to_string(),
company_id: legacy_company_echo(),
branch_id,
source_type: "inventory".into(),
source_id: lc_id,
source_reference: Some(lc_number.to_string()),
posting_date,
currency: currency.to_string(),
posting_type: "original".into(),
reverses_post_id: None,
description: Some("Landed cost".into()),
lines,
};
let dr_sum: Decimal = env.lines.iter().map(|l| l.debit).sum();
let cr_sum: Decimal = env.lines.iter().map(|l| l.credit).sum();
if dr_sum != abs(total) || cr_sum != abs(total) {
return Err(InventoryError::LandedCostAllocationMismatch {
lc_id, allocated: dr_sum, declared: abs(total),
});
}
Ok(env)
}
}
#[allow(dead_code)]
fn _money_is_half_up(v: Decimal) -> Decimal { money(v) }