1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
//! The Purchase Receipt path: draft → submit → repost (hand-authored, user-owned).
//!
//! An `impl InventoryWriteService` chunk over the vocabulary in [`super::inventory_write_service`]:
//! the goods-in voucher. `create_purchase_receipt` opens a draft; `submit_purchase_receipt` mints
//! ONE stock move per line through the converged engine (supplier location → the warehouse's
//! stock location) — move application runs the **inflow** half of the moving-average valuation
//! engine (`value += qty·rate; qty += qty; rate = value/qty`): the quant materializes, the Bin
//! reblends, the SLE row mints — then the voucher's ONE balanced `AccountingPost` (`Dr Inventory ·
//! Cr GR/IR`) is emitted and eventually reconciled; `repost_purchase_receipt` is the exit from a
//! stuck `failed` GL post. The moves post no GL of their own: the voucher owns the single
//! envelope its posting_state/repost/reversal machinery keys on.
//!
//! Per the module's 4-layer rule this file holds no SQL — the statements live on
//! `PurchaseReceiptRepository` / `PurchaseReceiptItemRepository` and the engine's repositories.
//!
//! Tenancy (ADR-0029): the module is tenant-agnostic. Every write transaction this file opens
//! re-binds the caller's ambient org scope (`relay_ambient_scope`); the composing decorator owns
//! isolation. Cross-module wire fields that still carry a company id are legacy twins filled
//! from the ambient scope's company echo.
use rust_decimal::Decimal;
use uuid::Uuid;
use crate::infrastructure::persistence::{GlVoucher, NewReceiptItemRow, NewReceiptRow};
use super::inventory_events::{InventoryEvent, StockReceived};
use super::inventory_gl::{AccountingPostEnvelope, GlPostLine, GlPostSink};
use super::inventory_move_engine::{BackorderPolicy, MoveGlDirective, NewStockMove};
use super::inventory_write_service::{
is_dup, legacy_company_echo, money, relay_ambient_scope, DoorOwnedGlSink, InventoryError,
InventoryWriteService, NewReceipt, SubmitOutcome,
};
impl InventoryWriteService {
pub async fn create_purchase_receipt(&self, r: NewReceipt) -> Result<Uuid, InventoryError> {
if r.lines.is_empty() { return Err(InventoryError::EmptyDocument); }
for l in &r.lines {
if l.quantity < Decimal::ZERO || l.rate < Decimal::ZERO { return Err(InventoryError::NegativeQuantity); }
}
// The document total values STOCK: a landed-cost service line (`is_landed_costs_line`)
// carries cost into a LandedCost document, not inventory — it mints no move and adds
// nothing to the door's Dr Inventory envelope (the landed cost posts its own GL with
// its own accounts; counting it here would debit inventory for stock never minted).
let total: Decimal = r.lines.iter()
.filter(|l| !l.is_landed_costs_line)
.map(|l| money(l.quantity * l.rate))
.sum();
let id = Uuid::new_v4();
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let ins = self.receipts.insert_draft(&mut tx, &NewReceiptRow {
id,
receipt_number: &r.receipt_number,
branch_id: r.branch_id,
supplier_id: r.supplier_id,
source_po_id: r.source_po_id,
warehouse_id: r.warehouse_id,
posting_date: r.posting_date,
currency: &r.currency,
total_value: total,
inventory_account_id: r.inventory_account_id,
grir_account_id: r.grir_account_id,
}).await;
if let Err(e) = ins {
return Err(if is_dup(&e) { InventoryError::DuplicateNumber(r.receipt_number) } else { e.into() });
}
for l in &r.lines {
self.receipt_items.insert_item(&mut tx, &NewReceiptItemRow {
id: Uuid::new_v4(),
receipt_id: id,
item_id: l.item_id,
quantity: l.quantity,
rate: l.rate,
amount: money(l.quantity * l.rate),
is_landed_costs_line: l.is_landed_costs_line,
}).await?;
}
tx.commit().await?;
Ok(id)
}
// ---- submit: Purchase Receipt (asset post) -----------------------------
/// Submit the goods-in voucher. Every line mints ONE stock move through the converged
/// engine (supplier location → the warehouse's stock location): confirm → assign (an
/// inbound move's supply is unconditionally available, so the execution line covers the
/// full line) → done, which materializes the destination quant, reblends the Bin
/// (`value += qty·rate; rate = value/qty`) and mints the SLE row. The voucher keeps its
/// identity verbatim — header status, the ONE `Dr Inventory · Cr GR/IR` envelope, the
/// posting_state/repost machinery, the `StockReceived` event; the moves post no GL (empty
/// directive + the door-owned-GL tripwire sink).
///
/// Not cross-move atomic (each engine verb commits its own transaction): a crash mid-way
/// leaves the receipt `draft` with some line moves landed — the deterministic move names
/// (`{receipt_number}/{seq}`, `origin` = the receipt number) make a re-submit RESUME
/// instead of double-receiving.
pub async fn submit_purchase_receipt(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
self.submit_purchase_receipt_into(id, None, sink).await
}
/// Submit a receipt into a destination other than the warehouse's stock location.
///
/// The ordinary receipt lands on the shelf, which is what a goods receipt means. A
/// subcontract receipt does not: the supplier's output belongs to an open manufacturing
/// order, and landing it on the shelf makes the estate believe in it twice, once here and
/// once when that order is received. The caller passes the location the goods are really
/// standing in — the production counterpart — and the manufacturing receive moves them onto
/// the shelf at their finished value.
///
/// Inventory does not know what subcontracting is and does not learn it here: it takes a
/// location. Deciding WHICH location belongs to whoever knows why the goods arrived.
pub async fn submit_purchase_receipt_into(
&self,
id: Uuid,
destination_location_id: Option<Uuid>,
sink: &dyn GlPostSink,
) -> Result<SubmitOutcome, InventoryError> {
// RLS scope (ADR-0008), ID-only: fenced by the request/inherited scope.
let hdr = self.receipts.fetch_submit_header(&self.rpool(), id).await?
.ok_or(InventoryError::NotFound(id))?;
if hdr.status != "draft" {
return Err(InventoryError::NotDraft(id.to_string()));
}
let branch = hdr.branch_id;
let warehouse = hdr.warehouse_id;
let posting_date = hdr.posting_date;
let voucher_no = hdr.receipt_number;
let inv_acct = hdr.inventory_account_id;
let grir_acct = hdr.grir_account_id;
let source_po = hdr.source_po_id;
let items = self.receipt_items.fetch_items(&self.rpool(), id).await?;
// The door's move endpoints: the supplier location (virtual source) and the
// warehouse's stock location (internal destination) — resolve-or-bootstrap each.
let (supplier_loc, default_stock_loc) = self.door_move_endpoints(warehouse, "supplier").await?;
// An override must still be a location this tenant can see; the fenced read refuses a
// stranger's id rather than moving goods somewhere nobody can account for.
let stock_loc = match destination_location_id {
None => default_stock_loc,
Some(requested) => backbone_orm::company_scope::fetch_optional_scalar_scoped(
&self.rpool(),
sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM inventory.locations WHERE id = $1 AND active",
)
.bind(requested),
)
.await?
.ok_or(InventoryError::NotFound(requested))?,
};
// The posting posture (the valuation settings row; absent row = today's shapes).
// The receipt side is unchanged in BOTH postures — the header `grir_account_id` IS the
// interim-received leg (Assumption A1 of the valuation overlay); only the inventory leg
// consults the location valuation-account override. `periodic` suppresses the
// real-time post below (the voucher retires to `not_applicable`).
let posture = self.posting_posture().await?;
let inv_acct = self.inventory_leg_account(stock_loc, inv_acct).await?;
// ---- physical movement: one minted move per line, engine-driven ----
// The enumerate index is the deterministic name grain (`{voucher}/{idx+1}`): a skipped
// line (zero qty, or a landed-cost service line that mints no stock) still CONSUMES its
// index, so the names stay aligned with the line rows across submit, cancel and resume.
for (idx, it) in items.iter().enumerate() {
if it.quantity.is_zero() { continue; } // a zero line moves nothing and mints nothing
// The landed-cost seam: a flagged line carries cost into a LandedCost document, not
// stock — no move, no quant, no bin, no SLE (the landed cost revalues the receipt's
// DONE moves; a service line has none).
if it.is_landed_costs_line { continue; }
let name = format!("{}/{}", voucher_no, idx + 1);
let mid = match self.mint_line_move(NewStockMove {
name: name.clone(),
item_id: it.item_id,
demand_qty: it.quantity,
price_unit: it.rate, // the IN leg values the inflow at the line's rate
procure_method: "make_to_stock".into(),
picking_id: None,
origin: Some(voucher_no.clone()),
location_id: supplier_loc,
location_dest_id: stock_loc,
partner_id: None,
warehouse_id: Some(warehouse),
orderpoint_id: None,
move_orig_ids: vec![],
move_dest_ids: vec![],
is_inventory: false,
scrapped: false,
forced_value: None,
}).await? {
Some(mid) => mid,
None => continue, // the line already landed in a prior (crashed) attempt
};
self.advance_move_to_assigned(mid).await?;
self.action_done(mid, BackorderPolicy::Never, &MoveGlDirective::default(), &DoorOwnedGlSink).await?;
}
// The GL amount is the voucher's own arithmetic (Σ money(qty·rate) over the STOCK
// lines) — identical to the Σ of the moves' IN-leg carries by construction. A landed
// cost service line is excluded on both sides: it minted nothing for the envelope to
// mirror.
let total_debit: Decimal = items.iter()
.filter(|l| !l.is_landed_costs_line)
.map(|l| money(l.quantity * l.rate))
.sum();
{
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
self.receipts.mark_submitted(&mut tx, id).await?;
tx.commit().await?;
}
// ---- GL post (eventually consistent) ----
// The explicit account-move gate: a voucher that carries neither value nor quantity
// posts nothing (stays `not_applicable`); a `periodic` valuation policy suppresses
// the real-time post the same way (the closing flow — a later increment — owns those legs).
let total_qty: Decimal = items.iter()
.filter(|l| !l.is_landed_costs_line)
.map(|l| l.quantity)
.sum();
if posture.periodic
|| !Self::should_create_account_move(total_debit, total_qty, true)
{
self.gl.mark_not_applicable(&self.rpool(), GlVoucher::PurchaseReceipt, id).await?;
self.sink.publish(InventoryEvent::StockReceived(StockReceived {
receipt_id: id, company_id: legacy_company_echo(), warehouse_id: warehouse, source_po_id: source_po,
total_value: total_debit,
}));
return Ok(SubmitOutcome {
voucher_id: id, posted: false, journal_id: None, post_id: None,
gl_amount: Decimal::ZERO,
});
}
let env = AccountingPostEnvelope {
// Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
// consumers that still read a tenant off the wire. No module statement keys on it.
idempotency_key: id.to_string(), company_id: legacy_company_echo(), branch_id: branch,
source_type: "inventory".into(), source_id: id, source_reference: Some(voucher_no.clone()),
posting_date, currency: hdr.currency.clone(), posting_type: "original".into(), reverses_post_id: None,
description: Some("Goods receipt".into()),
lines: vec![
GlPostLine::debit(inv_acct, total_debit).with_description("Inventory"),
GlPostLine::credit(grir_acct, total_debit).with_description("GR/IR clearing"),
],
};
let outcome = self.emit_and_reconcile(GlVoucher::PurchaseReceipt, id, &env, sink, total_debit).await?;
self.sink.publish(InventoryEvent::StockReceived(StockReceived {
receipt_id: id, company_id: legacy_company_echo(), warehouse_id: warehouse, source_po_id: source_po,
total_value: total_debit,
}));
Ok(outcome)
}
// ---- repost: re-drive a stuck GL post (pending/failed) ------------------
/// Re-emit the GL post for a voucher whose physical movement committed but whose post is
/// `pending` or `failed` (a transient accounting outage, or a crash between commit and the
/// status update). This is the exit from a stuck `failed` voucher (council 2026-07-04) — without
/// it, `(submitted, failed)` is terminal and the subledger silently stops tying to the GL.
///
/// **Idempotent** against the "physically-posted-but-status-not-updated" crash window: it rebuilds
/// the SAME envelope (`source_id = voucher_id`), and accounting dedupes on
/// `(company, source_type, source_id, posting_type)` — so a re-emit of an already-posted voucher
/// returns the original journal, never a second one. An already-`posted` voucher short-circuits
/// (returns the recorded ids); `not_applicable` is a no-op. Rebuilds the envelope from the stored
/// header (never re-touches the SLE/Bin — the physical movement already happened).
pub async fn repost_purchase_receipt(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
// RLS scope (ADR-0008), ID-only: fenced by the request/inherited scope.
let h = self.receipts.fetch_repost_header(&self.rpool(), id).await?
.ok_or(InventoryError::NotFound(id))?;
if let Some(o) = Self::already_settled(&h.gl, id) { return Ok(o); }
// The SAME posture the submit ran under (absent row = defaults): a `periodic`
// valuation policy suppresses the real-time post — the unsettled leg retires to
// `not_applicable` — and the inventory leg resolves the same location
// valuation-account override the submit used, so the rebuilt envelope is identical.
let posture = self.posting_posture().await?;
if posture.periodic {
self.gl.mark_not_applicable(&self.rpool(), GlVoucher::PurchaseReceipt, id).await?;
return Ok(SubmitOutcome {
voucher_id: id, posted: false, journal_id: None, post_id: None,
gl_amount: Decimal::ZERO,
});
}
let (_, stock_loc) = self.door_move_endpoints(h.warehouse_id, "supplier").await?;
let inv_acct = self.inventory_leg_account(stock_loc, h.inventory_account_id).await?;
let amt = h.total_value;
let env = AccountingPostEnvelope {
// Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
// consumers that still read a tenant off the wire. No module statement keys on it.
idempotency_key: id.to_string(), company_id: legacy_company_echo(), branch_id: h.branch_id,
source_type: "inventory".into(), source_id: id, source_reference: Some(h.receipt_number),
posting_date: h.posting_date, currency: h.currency.clone(), posting_type: "original".into(), reverses_post_id: None,
description: Some("Goods receipt (repost)".into()),
lines: vec![
GlPostLine::debit(inv_acct, amt).with_description("Inventory"),
GlPostLine::credit(h.grir_account_id, amt).with_description("GR/IR clearing"),
],
};
self.emit_and_reconcile(GlVoucher::PurchaseReceipt, id, &env, sink, amt).await
}
}