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
//! The scrap door (hand-authored, user-owned) — the stock.scrap satellite (spec
//! stock-business-logic.md §8).
//!
//! A scrap is an OPERATOR DOCUMENT with a genuine hand-set state (`draft` → `done` —
//! unlike the picking-batch projection), but the physical/valuation work rides the ONE
//! stock-move engine: `process_scrap` mints a single move (`scrapped = true`,
//! `is_inventory = true`) from the stock location to the scrap location, drives it
//! confirm → assign → done through the engine verbs, and stamps the header `done` with
//! the move id. No second estate: the quants, the SLE pair, the Bin reblende, and the GL
//! leg are all the engine's, exactly as for every other door.
//!
//! The scrap location defaults to the inventory-loss location (the same sink the
//! reconciliation door moves through), overridable per scrap. The GL shape is the
//! adjustment shape (the engine's `is_inventory` leg): the directive's
//! `adjustment_account_id` carries the scrap expense account, `inventory_account_id` the
//! stock account. The HTTP surface gets the DEFERRED form (the module's standing posture:
//! the composing service's sink is not available on a bare route); a service-driven
//! process posts through its sink, and `repost_move_gl` re-drives a stuck leg.
//!
//! 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.
//!
//! Per the module's 4-layer rule this file holds no SQL — statements live on
//! [`super::super::super::infrastructure::persistence::ScrapDoorRepository`].
use rust_decimal::Decimal;
use uuid::Uuid;
use crate::infrastructure::persistence::{NewScrapRow, ScrapRow};
use super::inventory_gl::GlPostSink;
use super::inventory_move_engine::{BackorderPolicy, MoveGlDirective, NewStockMove};
use super::inventory_write_service::{
is_dup, relay_ambient_scope, InventoryError, InventoryWriteService,
};
/// The scrap-mint input. `scrap_location_id` defaults to the inventory-loss
/// location when `None`. Guards on mint: strictly positive quantity (R-shaped), a
/// stockable internal source location (R13).
#[derive(Debug, Clone)]
pub struct NewScrap {
pub item_id: Uuid,
pub scrap_qty: Decimal,
/// Where the scrapped stock currently sits (internal usage).
pub location_id: Uuid,
/// Where the scrap lands (the loss sink); `None` resolves the default loss sink.
pub scrap_location_id: Option<Uuid>,
pub lot_id: Option<Uuid>,
pub package_id: Option<Uuid>,
pub owner_id: Option<Uuid>,
pub picking_id: Option<Uuid>,
pub origin: Option<String>,
pub scrap_reason_tag_ids: Vec<Uuid>,
}
/// The scrap process outcome: the closed header and the engine move it landed through.
#[derive(Debug, Clone)]
pub struct ScrapProcessed {
pub scrap_id: Uuid,
pub move_id: Uuid,
}
impl InventoryWriteService {
// ---- scrap: mint / process / probe ---------------------------------------------
/// Mint a scrap order in `draft`. Nothing moves yet — the header records WHAT will be
/// scrapped, where from, and where to. Guards: strictly positive quantity, a live
/// internal source location, a resolvable scrap location. The name is
/// minted `SCRAP/<short-uuid>` (org-scoped unique — the typed duplicate error on the
/// rare collision).
pub async fn create_scrap(&self, s: NewScrap) -> Result<ScrapRow, InventoryError> {
if s.scrap_qty <= Decimal::ZERO {
return Err(InventoryError::NegativeQuantity);
}
let id = Uuid::new_v4();
let name = format!("SCRAP/{}", &Uuid::new_v4().simple().to_string()[..8].to_uppercase());
let mut tx = self.rpool().begin().await?;
// Re-bind the caller's ambient org scope before any read (ADR-0029) — the scope is
// task-local and a fresh pool transaction carries none of it; undecorated (module
// tests, jobs) the transaction stays plain.
relay_ambient_scope(&mut tx).await?;
// R13-shaped pre-check: the source must be a stockable INTERNAL location (a view
// location holds no stock).
let locs = self.moves.fetch_move_locations(&mut tx, s.location_id, s.location_id).await?;
let src = locs.0.ok_or(InventoryError::LocationNotFound(s.location_id))?;
if src.usage != "internal" {
return Err(InventoryError::ViewLocationHoldsNoStock { location_id: s.location_id });
}
// The scrap sink: explicit when given, else the inventory-loss location
// (bootstrapped on first use — the same sink the reconciliation door uses).
let scrap_location_id = match s.scrap_location_id {
Some(explicit) => {
let dst = self.moves.fetch_move_locations(&mut tx, explicit, explicit).await?;
match dst.1.or(dst.0) {
Some(facts) if facts.usage == "inventory" => explicit,
_ => return Err(InventoryError::ScrapLocationUnavailable),
}
}
None => self.pickings.ensure_inventory_loss_location(&mut tx).await
.map_err(|_| InventoryError::ScrapLocationUnavailable)?,
};
let ins = self.scraps.insert_scrap(&mut tx, &NewScrapRow {
id,
name: &name,
origin: s.origin.as_deref(),
item_id: s.item_id,
scrap_qty: s.scrap_qty,
location_id: s.location_id,
scrap_location_id,
lot_id: s.lot_id,
package_id: s.package_id,
owner_id: s.owner_id,
picking_id: s.picking_id,
scrap_reason_tag_ids: s.scrap_reason_tag_ids,
}).await;
if let Err(e) = ins {
return Err(if is_dup(&e) { InventoryError::DuplicateNumber(name) } else { e.into() });
}
tx.commit().await?;
self.fetch_scrap(id).await?
.ok_or(InventoryError::NotFound(id))
}
/// The probe: read one scrap header, riding the caller's ambient org scope (ADR-0029).
pub async fn fetch_scrap(&self, scrap_id: Uuid) -> Result<Option<ScrapRow>, InventoryError> {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let row = self.scraps.fetch_scrap(&mut tx, scrap_id).await?;
tx.commit().await?;
Ok(row)
}
/// Process a DRAFT scrap: mint the move, drive it to done through the engine, stamp
/// the header. The full-GL form — the caller's directive carries the accounts and the
/// sink posts the envelope. The move is `is_inventory` (the engine's adjustment leg
/// shape: the directive's `adjustment_account_id` is the scrap expense account) and
/// `scrapped` (the engine's marker for scrap-marked moves). A scrap lands WHOLE: the
/// backorder policy is `Never` and a move that cannot fully reserve refuses loudly
/// (you cannot scrap stock that is not there — R22).
///
/// Crash-window safe: the done-stamp's `state = 'draft'` guard means exactly one
/// process call closes the header; a crash after the move landed but before the stamp
/// leaves the scrap re-processable, and the resume check below re-drives the SAME
/// move instead of minting a second one.
pub async fn process_scrap(
&self,
scrap_id: Uuid,
gl: &MoveGlDirective,
sink: &dyn GlPostSink,
) -> Result<ScrapProcessed, InventoryError> {
let scrap = self.fetch_scrap(scrap_id).await?
.ok_or(InventoryError::NotFound(scrap_id))?;
match scrap.state.as_str() {
"draft" => {}
other => return Err(InventoryError::ScrapNotDraft { scrap_id, state: other.into() }),
}
let move_id = match scrap.move_id {
// Resume: a prior process minted the move but did not close the header (crash
// between done and stamp). Re-driving the same move keeps the estate single.
Some(prior) => prior,
None => self.create_move(NewStockMove {
name: scrap.name.clone(),
item_id: scrap.item_id,
demand_qty: scrap.scrap_qty,
price_unit: Decimal::ZERO, // the loss is valued at the current average
procure_method: "make_to_stock".into(),
picking_id: None,
origin: scrap.origin.clone(),
location_id: scrap.location_id,
location_dest_id: scrap.scrap_location_id,
partner_id: None,
warehouse_id: None,
orderpoint_id: None,
move_orig_ids: vec![],
move_dest_ids: vec![],
is_inventory: true,
scrapped: true,
forced_value: None,
}).await?,
};
// Drive the pipeline, re-reading the state between verbs (the engine owns the
// state; this method never asserts it).
let mv = {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let mv = self.moves.fetch_move(&mut tx, move_id).await?
.ok_or(InventoryError::NotFound(move_id))?;
tx.commit().await?;
mv
};
let mut state = mv.state.clone();
if state == "draft" {
state = self.action_confirm(move_id).await?;
}
if matches!(state.as_str(), "confirmed" | "partially_available") {
self.action_assign(move_id).await?;
state = self.move_state_of(move_id).await?;
}
if state == "assigned" || state == "waiting" {
// The waiting case is the resume window's residue (a move confirmed against
// nothing reservable); the assign pass above is what freed or refused it.
let fresh = self.move_state_of(move_id).await?;
if fresh == "waiting" {
return Err(InventoryError::WrongMoveState {
move_id, action: "process_scrap", current: fresh,
});
}
state = fresh;
}
if state != "assigned" {
return Err(InventoryError::WrongMoveState {
move_id, action: "process_scrap", current: state,
});
}
let mv = {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let mv = self.moves.fetch_move(&mut tx, move_id).await?
.ok_or(InventoryError::NotFound(move_id))?;
tx.commit().await?;
mv
};
self.prepare_move_for_validate(&mv).await?;
self.action_done(move_id, BackorderPolicy::Never, gl, sink).await?;
{
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let closed = self.scraps.mark_scrap_done(&mut tx, scrap_id, move_id).await?;
tx.commit().await?;
if !closed {
return Err(InventoryError::ScrapNotDraft { scrap_id, state: "done".into() });
}
}
Ok(ScrapProcessed { scrap_id, move_id })
}
/// The HTTP-shaped process: the SAME physical movement (the move lands done, the
/// quants flip, the SLEs mint), but the GL leg carries a no-accounts directive — the
/// engine builds no envelope and the move's posting stays `not_applicable` until a
/// service-driven [`Self::repost_move_gl`] drives it with real accounts (the module's
/// standing deferred posture for GL-posting verbs on a bare route).
pub async fn process_scrap_deferred(
&self,
scrap_id: Uuid,
) -> Result<ScrapProcessed, InventoryError> {
let no_accounts = MoveGlDirective {
cogs_account_id: None,
inventory_account_id: None,
grir_account_id: None,
adjustment_account_id: None,
currency: "IDR".into(),
};
struct NoGlSink;
#[async_trait::async_trait]
impl GlPostSink for NoGlSink {
async fn post(
&self,
_e: &super::inventory_gl::AccountingPostEnvelope,
) -> Result<super::inventory_gl::GlPostAck, super::inventory_gl::GlPostRejected> {
Err(super::inventory_gl::GlPostRejected {
code: "no_gl_on_scrap_deferred".into(),
message: "the deferred scrap process posts no GL; drive repost_move_gl".into(),
})
}
}
self.process_scrap(scrap_id, &no_accounts, &NoGlSink).await
}
}