Skip to main content

backbone_inventory/application/service/
inventory_intake.rs

1//! Inbound intake (hand-authored, user-owned) — the `DeliveryRequested` port the selling↔inventory
2//! delivery seam triggers on (`docs/erp/modules/backbone-inventory.md` §3, extension-guide).
3//!
4//! Selling, when a Sales Order is ready to ship, emits `DeliveryRequested`; inventory turns it into
5//! a **draft** Delivery Note (the physical move is a separate `submit_delivery_note(sink)` step, so
6//! the GL post + SLE happen under the composing service's control). The GL account ids are inventory
7//! config (resolved by the composing service / an item-account map), not selling's concern, so they
8//! ride on the intake DTO. This is the inventory-owned half of the seam — a consumer wires the event
9//! bus to `DeliveryIntake::on_delivery_requested`.
10//!
11//! Tenancy (ADR-0029): the module is tenant-agnostic. These intake DTOs are ACL-mapped inputs
12//! (the composition layer builds them from the producer module's wire envelope); the writes they
13//! trigger ride the ambient org scope the composing service set per request.
14
15use rust_decimal::Decimal;
16use serde::{Deserialize, Serialize};
17use sqlx::PgPool;
18use uuid::Uuid;
19
20use super::inventory_write_service::{
21    DeliveryLine, InventoryError, InventoryWriteService, NewDelivery, NewReceipt, ReceiptLine,
22};
23
24/// Serde default for the optional `currency` field on inbound intake requests. Existing callers that
25/// don't send a currency get "IDR" (the module's historical single-currency behavior).
26fn default_currency() -> String { "IDR".into() }
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct DeliveryRequestLine {
30    pub item_id: Uuid,
31    pub quantity: Decimal,
32}
33
34/// The inbound request selling emits to have stock shipped for a confirmed order.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct DeliveryRequested {
37    pub delivery_number: String,
38    pub branch_id: Option<Uuid>,
39    pub customer_id: Uuid,
40    pub source_so_id: Option<Uuid>,
41    pub warehouse_id: Uuid,
42    pub posting_date: chrono::NaiveDate,
43    /// Ledger currency of the GL post (defaults to "IDR" when omitted).
44    #[serde(default = "default_currency")]
45    pub currency: String,
46    /// GL accounts are inventory config, supplied by the composing service (not by selling).
47    pub cogs_account_id: Uuid,
48    pub inventory_account_id: Uuid,
49    pub lines: Vec<DeliveryRequestLine>,
50}
51
52/// One line of a receipt request (what buying asks inventory to receive; carries the unit cost).
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct ReceiptRequestLine {
55    pub item_id: Uuid,
56    pub quantity: Decimal,
57    pub rate: Decimal,
58}
59
60/// The inbound request buying emits to have goods received against a confirmed Purchase Order.
61/// GL accounts (Inventory + GR/IR) are inventory config, supplied by the composing service.
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct ReceiptExpected {
64    pub receipt_number: String,
65    pub branch_id: Option<Uuid>,
66    pub supplier_id: Uuid,
67    pub source_po_id: Option<Uuid>,
68    pub warehouse_id: Uuid,
69    pub posting_date: chrono::NaiveDate,
70    /// Ledger currency of the GL post (defaults to "IDR" when omitted).
71    #[serde(default = "default_currency")]
72    pub currency: String,
73    pub inventory_account_id: Uuid,
74    pub grir_account_id: Uuid,
75    pub lines: Vec<ReceiptRequestLine>,
76}
77
78/// The intake handler — a consumer subscribes the event bus to these.
79#[derive(Clone)]
80pub struct DeliveryIntake {
81    write: InventoryWriteService,
82}
83
84impl DeliveryIntake {
85    pub fn new(pool: PgPool) -> Self {
86        Self { write: InventoryWriteService::new(pool) }
87    }
88
89    /// Turn a `DeliveryRequested` into a DRAFT delivery note; returns its id. The caller then
90    /// `submit_delivery_note(id, sink)` to perform the physical move + COGS post.
91    pub async fn on_delivery_requested(&self, req: DeliveryRequested) -> Result<Uuid, InventoryError> {
92        self.write.create_delivery_note(NewDelivery {
93            delivery_number: req.delivery_number,
94            branch_id: req.branch_id,
95            customer_id: req.customer_id,
96            source_so_id: req.source_so_id,
97            warehouse_id: req.warehouse_id,
98            posting_date: req.posting_date,
99            currency: req.currency,
100            cogs_account_id: req.cogs_account_id,
101            inventory_account_id: req.inventory_account_id,
102            lines: req.lines.into_iter().map(|l| DeliveryLine { item_id: l.item_id, quantity: l.quantity }).collect(),
103        }).await
104    }
105
106    /// Turn a `ReceiptExpected` (from buying) into a DRAFT purchase receipt; returns its id. The
107    /// caller then `submit_purchase_receipt(id, sink)` to perform the physical move + asset post.
108    /// The receipt-side mirror of `on_delivery_requested`.
109    pub async fn on_receipt_expected(&self, req: ReceiptExpected) -> Result<Uuid, InventoryError> {
110        self.write.create_purchase_receipt(NewReceipt {
111            receipt_number: req.receipt_number,
112            branch_id: req.branch_id,
113            supplier_id: req.supplier_id,
114            source_po_id: req.source_po_id,
115            warehouse_id: req.warehouse_id,
116            posting_date: req.posting_date,
117            currency: req.currency,
118            inventory_account_id: req.inventory_account_id,
119            grir_account_id: req.grir_account_id,
120            // A delivery request never carries a landed-cost service line, so the seam flag
121            // is statically false here (the seam is owned by inventory's receipt surface).
122            lines: req.lines.into_iter().map(|l| ReceiptLine { item_id: l.item_id, quantity: l.quantity, rate: l.rate, is_landed_costs_line: false }).collect(),
123        }).await
124    }
125}