@spinekit/purchase 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +75 -0
- package/README.md +40 -0
- package/dist/bridges.d.mts +167 -0
- package/dist/bridges.mjs +151 -0
- package/dist/entity-model-D-03ovSg.mjs +23 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +140 -0
- package/dist/lifecycle/purchase-lifecycle.d.mts +17 -0
- package/dist/lifecycle/purchase-lifecycle.mjs +120 -0
- package/dist/lifecycle/purchase-lifecycle.types.d.mts +32 -0
- package/dist/lifecycle/purchase-lifecycle.types.mjs +1 -0
- package/dist/payment/purchase-payment.application.d.mts +8 -0
- package/dist/payment/purchase-payment.application.mjs +154 -0
- package/dist/payment/purchase-payment.tax.d.mts +30 -0
- package/dist/payment/purchase-payment.tax.mjs +37 -0
- package/dist/payment/purchase-payment.types.d.mts +2 -0
- package/dist/payment/purchase-payment.types.mjs +1 -0
- package/dist/purchase-payment.types-hVKAeW3G.d.mts +153 -0
- package/dist/receipt/purchase-stock-receipt.d.mts +12 -0
- package/dist/receipt/purchase-stock-receipt.mjs +225 -0
- package/dist/receipt/purchase-stock-receipt.types.d.mts +167 -0
- package/dist/receipt/purchase-stock-receipt.types.mjs +1 -0
- package/dist/repositories/purchase-order.repository.d.mts +40 -0
- package/dist/repositories/purchase-order.repository.mjs +111 -0
- package/dist/resources/purchase-order/purchase-order.resource.d.mts +56 -0
- package/dist/resources/purchase-order/purchase-order.resource.mjs +193 -0
- package/dist/resources/supplier/supplier.model.d.mts +2 -0
- package/dist/resources/supplier/supplier.model.mjs +178 -0
- package/dist/resources/supplier/supplier.repository.d.mts +20 -0
- package/dist/resources/supplier/supplier.repository.mjs +62 -0
- package/dist/resources/supplier/supplier.resource.d.mts +34 -0
- package/dist/resources/supplier/supplier.resource.mjs +129 -0
- package/dist/resources/supplier/supplier.types.d.mts +2 -0
- package/dist/resources/supplier/supplier.types.mjs +1 -0
- package/dist/supplier.model-BcAfgyHu.d.mts +88 -0
- package/dist/types-T9gsXOf_.d.mts +127 -0
- package/package.json +129 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { ConflictError } from "@classytic/arc/utils";
|
|
2
|
+
import { isDuplicateKeyError } from "@classytic/mongokit";
|
|
3
|
+
import { RATE_SCALE } from "@classytic/primitives/unit-cost-rate";
|
|
4
|
+
//#region src/receipt/purchase-stock-receipt.ts
|
|
5
|
+
/**
|
|
6
|
+
* Book a received purchase order into stock — the purchase→WMS leg.
|
|
7
|
+
*
|
|
8
|
+
* Lifted from be-prod (`purchase-order/actions/receive-items-into-stock.ts`). It is the
|
|
9
|
+
* kernel's `StockReceiptBridge` contract implemented over a warehouse, and every host that
|
|
10
|
+
* receives goods needs it; leaving it in one host is how the second host books stock slightly
|
|
11
|
+
* differently and nobody can say which is right.
|
|
12
|
+
*
|
|
13
|
+
* Three things here are load-bearing and easy to lose in a rewrite:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Idempotency is by `purchaseId`** on the receipt move group — purchase 0.2's crash
|
|
16
|
+
* healing depends on it. A retry after a crash between the PO's receive CAS and this call
|
|
17
|
+
* must find the group a partial run created instead of double-booking stock.
|
|
18
|
+
* 2. **Convergence is BY STATE, not by existence.** A crash can land between `create` and
|
|
19
|
+
* `confirm`, or between `confirm` and `receive`. An existing group only proves the chain
|
|
20
|
+
* STARTED; only `done` is a true no-op. Treating "exists" as "finished" leaves stock
|
|
21
|
+
* permanently unbooked with a PO that says received.
|
|
22
|
+
* 3. **An unresolvable destination is collected, not thrown.** One bad line must not abort a
|
|
23
|
+
* 20-line receipt. Everything else propagates — see `isUnresolvable` in the types.
|
|
24
|
+
*
|
|
25
|
+
* ## The cost seam
|
|
26
|
+
*
|
|
27
|
+
* PO lines persist integer MINOR units (schemaVersion 2). The warehouse wants a scaled-integer
|
|
28
|
+
* rate, so the conversion is minor → scaled DIRECTLY (`RATE_SCALE`), never through a
|
|
29
|
+
* major-unit float: a major value needs a currency to interpret and the round trip loses
|
|
30
|
+
* precision. Nothing in this file computes a major amount, which is what makes it usable
|
|
31
|
+
* under any currency.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Integer MINOR units → the warehouse's scaled-integer rate.
|
|
35
|
+
*
|
|
36
|
+
* `RATE_SCALE` is the primitive's constant, not a local literal — a second copy of the scale
|
|
37
|
+
* is a silent 1e6 valuation error the day one of them moves.
|
|
38
|
+
*/
|
|
39
|
+
function minorToScaledRate(costMinor) {
|
|
40
|
+
return Math.round(costMinor * RATE_SCALE);
|
|
41
|
+
}
|
|
42
|
+
/** The warehouse's scaled rate back to integer MINOR units. */
|
|
43
|
+
function scaledRateToMinor(scaled) {
|
|
44
|
+
return Math.round(scaled / RATE_SCALE);
|
|
45
|
+
}
|
|
46
|
+
function createPurchaseStockReceipt(deps) {
|
|
47
|
+
return {
|
|
48
|
+
/**
|
|
49
|
+
* Book every receivable line, returning the lines that could not be resolved.
|
|
50
|
+
*
|
|
51
|
+
* Safe to retry: see the idempotency and convergence notes in the module header.
|
|
52
|
+
*/
|
|
53
|
+
async receive({ purchase, supplierName }) {
|
|
54
|
+
const warehouse = deps.warehouse();
|
|
55
|
+
const ctx = deps.buildContext(String(purchase.branch), String(purchase.createdBy ?? ""));
|
|
56
|
+
const resolver = deps.locations.create(ctx);
|
|
57
|
+
const errors = [];
|
|
58
|
+
const resolved = [];
|
|
59
|
+
for (const item of purchase.items) {
|
|
60
|
+
const productId = String(item.product);
|
|
61
|
+
const variantSku = item.variantSku || null;
|
|
62
|
+
try {
|
|
63
|
+
const destinationLocationCode = await resolver.resolve(item.destinationLocationId);
|
|
64
|
+
resolved.push({
|
|
65
|
+
productId,
|
|
66
|
+
variantSku,
|
|
67
|
+
skuRef: deps.skuRef(productId, variantSku),
|
|
68
|
+
quantity: Number(item.quantity ?? 0),
|
|
69
|
+
costMinor: Number(item.costPrice ?? 0),
|
|
70
|
+
destinationLocationCode
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (deps.locations.isUnresolvable(error)) errors.push({
|
|
74
|
+
productId,
|
|
75
|
+
variantSku,
|
|
76
|
+
error: error.message
|
|
77
|
+
});
|
|
78
|
+
else throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* REFUSE BEFORE MUTATING — every location is resolved above, and if ANY line failed to
|
|
83
|
+
* resolve nothing is booked.
|
|
84
|
+
*
|
|
85
|
+
* This guard was missing, and its absence broke the all-or-nothing receive the host
|
|
86
|
+
* promises. The old sequence was: resolve all lines → collect per-line errors → **book
|
|
87
|
+
* the ones that resolved** → return the errors. `purchase.engine.ts` turns a non-empty
|
|
88
|
+
* error list into a thrown `createStatusError`, so the purchase kernel then compensates
|
|
89
|
+
* the PO's receive state — while the quants, move group and cost layers written for the
|
|
90
|
+
* VALID lines stay. One bad `destinationLocationId` in a ten-line PO left the PO
|
|
91
|
+
* un-received and the other nine lines' stock booked, with nothing to reconcile against.
|
|
92
|
+
*
|
|
93
|
+
* Ordering is the whole fix: resolution is a pure read, so it is safe to complete for
|
|
94
|
+
* every line first and decide afterwards. Nothing below this line runs on a partial
|
|
95
|
+
* resolution — not `ensureBranchReady`, not the move group, not the quant updates, and
|
|
96
|
+
* not the cost-only snapshots in the second loop.
|
|
97
|
+
*
|
|
98
|
+
* If PARTIAL receipt is ever wanted, it needs modelling explicitly — per-line received
|
|
99
|
+
* quantities and a `partially_received` state. "Mutate what resolved, report the rest,
|
|
100
|
+
* let the caller throw" is not a partial-receipt design; it is an unreconcilable one.
|
|
101
|
+
*/
|
|
102
|
+
if (errors.length > 0) return errors;
|
|
103
|
+
const receivable = resolved.filter((line) => line.quantity > 0);
|
|
104
|
+
if (receivable.length > 0) {
|
|
105
|
+
await deps.ensureBranchReady(ctx.organizationId);
|
|
106
|
+
/**
|
|
107
|
+
* The RACE FENCE for this receipt.
|
|
108
|
+
*
|
|
109
|
+
* `getByQuery` → `create` below is a check-then-act: two concurrent retries of a
|
|
110
|
+
* crashed receive both read `null` and BOTH book the stock. Nothing throws and the
|
|
111
|
+
* PO looks correctly received — the warehouse is simply holding twice what arrived.
|
|
112
|
+
*
|
|
113
|
+
* `demandKey` is unique-partial-indexed on flow's move-group model, so the second
|
|
114
|
+
* create is rejected at the database and its caller replays the winner instead. The
|
|
115
|
+
* lookup below is still the primary path (it converges a group whose key was already
|
|
116
|
+
* freed by completing); the key only closes the concurrent window the lookup cannot.
|
|
117
|
+
*/
|
|
118
|
+
const demandKey = `receipt:${String(purchase._id)}`;
|
|
119
|
+
const findExisting = async () => await warehouse.repositories.moveGroup.getByQuery({
|
|
120
|
+
groupType: "receipt",
|
|
121
|
+
"metadata.purchaseId": String(purchase._id)
|
|
122
|
+
}, {
|
|
123
|
+
organizationId: ctx.organizationId,
|
|
124
|
+
throwOnNotFound: false,
|
|
125
|
+
lean: true
|
|
126
|
+
});
|
|
127
|
+
/**
|
|
128
|
+
* CONVERGE BY STATE — header note 2. Drive the group to `done` from wherever it
|
|
129
|
+
* stopped rather than assuming a pre-existing group finished.
|
|
130
|
+
*
|
|
131
|
+
* Extracted so the duplicate-key LOSER runs the identical convergence. Two copies of
|
|
132
|
+
* this switch is how one of them would eventually learn about a new status and the
|
|
133
|
+
* other would fall through to its `default` and throw on a healthy group.
|
|
134
|
+
*/
|
|
135
|
+
const converge = async (group) => {
|
|
136
|
+
const groupId = String(group._id);
|
|
137
|
+
switch (group.status) {
|
|
138
|
+
case "done": break;
|
|
139
|
+
case "draft":
|
|
140
|
+
await warehouse.services.moveGroup.executeAction(groupId, "confirm", {}, ctx);
|
|
141
|
+
await warehouse.services.moveGroup.executeAction(groupId, "receive", {}, ctx);
|
|
142
|
+
break;
|
|
143
|
+
case "confirmed":
|
|
144
|
+
case "allocated":
|
|
145
|
+
case "in_progress":
|
|
146
|
+
case "partially_done":
|
|
147
|
+
await warehouse.services.moveGroup.executeAction(groupId, "receive", {}, ctx);
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
/**
|
|
151
|
+
* A cancelled (or unknown-state) receipt group under a PO being received is a
|
|
152
|
+
* reconciliation problem no retry can solve. Surface it — re-booking would
|
|
153
|
+
* double the stock and skipping would leave it permanently unbooked, and both
|
|
154
|
+
* look like success from the caller's side.
|
|
155
|
+
*/
|
|
156
|
+
throw new ConflictError(`Purchase ${String(purchase._id)} has an existing receipt moveGroup ${groupId} in state '${group.status ?? "unknown"}' — manual reconciliation required (cancel-and-recreate or complete it in the WMS before retrying the receive).`);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const existing = await findExisting();
|
|
160
|
+
if (existing) await converge(existing);
|
|
161
|
+
else {
|
|
162
|
+
const group = await warehouse.services.moveGroup.create({
|
|
163
|
+
groupType: "receipt",
|
|
164
|
+
demandKey,
|
|
165
|
+
metadata: {
|
|
166
|
+
purchaseId: String(purchase._id),
|
|
167
|
+
supplierInvoice: purchase.invoiceNumber,
|
|
168
|
+
purchaseOrderNumber: purchase.purchaseOrderNumber,
|
|
169
|
+
vendorRef: supplierName || "unknown-vendor",
|
|
170
|
+
notes: purchase.notes
|
|
171
|
+
},
|
|
172
|
+
items: receivable.map((line) => ({
|
|
173
|
+
moveGroupId: "",
|
|
174
|
+
operationType: "receipt",
|
|
175
|
+
skuRef: line.skuRef,
|
|
176
|
+
sourceLocationId: deps.vendorLocationId,
|
|
177
|
+
destinationLocationId: line.destinationLocationCode,
|
|
178
|
+
quantityPlanned: line.quantity,
|
|
179
|
+
metadata: { unitCostScaled: minorToScaledRate(line.costMinor) }
|
|
180
|
+
}))
|
|
181
|
+
}, ctx).catch(async (err) => {
|
|
182
|
+
if (!isDuplicateKeyError(err)) throw err;
|
|
183
|
+
const winner = await findExisting();
|
|
184
|
+
if (!winner) throw err;
|
|
185
|
+
await converge(winner);
|
|
186
|
+
return null;
|
|
187
|
+
});
|
|
188
|
+
if (group !== null) {
|
|
189
|
+
await warehouse.services.moveGroup.executeAction(String(group._id), "confirm", {}, ctx);
|
|
190
|
+
await warehouse.services.moveGroup.executeAction(String(group._id), "receive", {}, ctx);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const line of resolved) {
|
|
195
|
+
/**
|
|
196
|
+
* Zero-quantity line with a price = a COST-ONLY update. `quantityDelta: 0` seeds the
|
|
197
|
+
* valuation without moving stock, which is how a price correction is applied to a SKU
|
|
198
|
+
* that has none on hand.
|
|
199
|
+
*/
|
|
200
|
+
if (line.quantity === 0 && line.costMinor > 0) {
|
|
201
|
+
await warehouse.repositories.quant.upsert({
|
|
202
|
+
organizationId: ctx.organizationId,
|
|
203
|
+
skuRef: line.skuRef,
|
|
204
|
+
locationId: line.destinationLocationCode,
|
|
205
|
+
quantityDelta: 0,
|
|
206
|
+
unitCostScaled: minorToScaledRate(line.costMinor),
|
|
207
|
+
inDate: /* @__PURE__ */ new Date()
|
|
208
|
+
});
|
|
209
|
+
await deps.costSnapshot.set(line.productId, line.variantSku, line.costMinor);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (line.quantity > 0 && line.costMinor > 0) {
|
|
213
|
+
const settledScaled = (await warehouse.services.quant.getAvailability({
|
|
214
|
+
skuRef: line.skuRef,
|
|
215
|
+
locationId: line.destinationLocationCode
|
|
216
|
+
}, ctx)).breakdowns?.[0]?.unitCostScaled;
|
|
217
|
+
const settledMinor = typeof settledScaled === "number" && settledScaled > 0 ? scaledRateToMinor(settledScaled) : line.costMinor;
|
|
218
|
+
await deps.costSnapshot.set(line.productId, line.variantSku, settledMinor);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return errors;
|
|
222
|
+
} };
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
export { createPurchaseStockReceipt };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { PurchaseOrderDocument } from "@classytic/purchase";
|
|
2
|
+
//#region src/receipt/purchase-stock-receipt.types.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The MINIMUM a receipt scope must carry. `organizationId` is the branch, per the
|
|
5
|
+
* Flow convention.
|
|
6
|
+
*
|
|
7
|
+
* Every port below is GENERIC over the host's real context type rather than
|
|
8
|
+
* taking this one directly, and that is the point: a host whose context is a
|
|
9
|
+
* `FlowContext` (carrying `actorId`, and whatever else its WMS needs) got this
|
|
10
|
+
* lossy shape back at every call site, so it cast — `buildContext(...) as never`
|
|
11
|
+
* on the way in and `ctx as unknown as FlowContext` on the way out.
|
|
12
|
+
*
|
|
13
|
+
* `as never` is the expensive half. It does not narrow one field, it erases the
|
|
14
|
+
* check for ALL of them, so a genuinely wrong context would have compiled just
|
|
15
|
+
* as happily. Removing it is what revealed the gap in the first place.
|
|
16
|
+
*
|
|
17
|
+
* The index signature stays so this remains satisfiable by a plain object; the
|
|
18
|
+
* generic is what stops a host from LOSING its own type by passing through here.
|
|
19
|
+
*/
|
|
20
|
+
interface PurchaseReceiptContext {
|
|
21
|
+
organizationId: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The CONSTRAINT every port is generic over — deliberately NOT
|
|
26
|
+
* {@link PurchaseReceiptContext}.
|
|
27
|
+
*
|
|
28
|
+
* That type carries an index signature so a plain object literal satisfies it.
|
|
29
|
+
* As a constraint the index signature is fatal: a concrete interface (a host's
|
|
30
|
+
* `FlowContext`) does not satisfy `[key: string]: unknown`, so
|
|
31
|
+
* `<TCtx extends PurchaseReceiptContext>` rejects exactly the real-world types
|
|
32
|
+
* the generic exists to admit. `tsc` says so plainly — "Type 'FlowContext' does
|
|
33
|
+
* not satisfy the constraint" — which is how this was caught.
|
|
34
|
+
*
|
|
35
|
+
* So the constraint states the one thing a receipt scope must have, and nothing
|
|
36
|
+
* about what else it may carry.
|
|
37
|
+
*/
|
|
38
|
+
interface PurchaseReceiptScope {
|
|
39
|
+
organizationId: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The Flow surface this bridge drives — structurally typed, so a deployment can satisfy it
|
|
43
|
+
* with `@classytic/flow` (what be-prod does) or anything shaped like it.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* What the bridge SENDS to `moveGroup.create` — every field optional ON THE PORT,
|
|
47
|
+
* deliberately (the `@spinekit/manufacturing/flow-stock` pattern, 2026-08-14): a
|
|
48
|
+
* provider's own input interface (flow's `CreateMoveGroupServiceInput`) carries no
|
|
49
|
+
* index signature, so it never assigns to `Record<string, unknown>`, and required
|
|
50
|
+
* fields here would fail the other bivariant direction. All-optional is the one
|
|
51
|
+
* shape both sides satisfy CASTLESS — which is the point: the old
|
|
52
|
+
* `getFlowEngine() as unknown as PurchaseReceiptWarehouse` in the host erased
|
|
53
|
+
* every field-name drift at this seam, and the same erasure hid a live
|
|
54
|
+
* field-name bug in the manufacturing adapter (`available` vs
|
|
55
|
+
* `quantityAvailable`). The bridge is the only caller and always sends the full
|
|
56
|
+
* shape.
|
|
57
|
+
*/
|
|
58
|
+
interface PurchaseReceiptGroupCreateInput {
|
|
59
|
+
groupType?: string;
|
|
60
|
+
demandKey?: string;
|
|
61
|
+
metadata?: unknown;
|
|
62
|
+
items?: unknown;
|
|
63
|
+
}
|
|
64
|
+
interface PurchaseReceiptWarehouse<TCtx extends PurchaseReceiptScope = PurchaseReceiptContext> {
|
|
65
|
+
repositories: {
|
|
66
|
+
moveGroup: {
|
|
67
|
+
getByQuery(filter: Record<string, unknown>, options: Record<string, unknown>): Promise<unknown>;
|
|
68
|
+
};
|
|
69
|
+
quant: {
|
|
70
|
+
upsert(input: {
|
|
71
|
+
organizationId: string;
|
|
72
|
+
skuRef: string;
|
|
73
|
+
locationId: string;
|
|
74
|
+
quantityDelta: number;
|
|
75
|
+
unitCostScaled: number;
|
|
76
|
+
inDate: Date;
|
|
77
|
+
}): Promise<unknown>;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
services: {
|
|
81
|
+
moveGroup: {
|
|
82
|
+
/**
|
|
83
|
+
* Accepts a `demandKey` — the unique-indexed race fence for one group per unit of
|
|
84
|
+
* demand. An implementation that ignores it will DOUBLE-BOOK a concurrently retried
|
|
85
|
+
* receipt, so a provider that cannot enforce it is not a valid provider here; the
|
|
86
|
+
* bridge relies on a duplicate-key rejection, not on the field being decorative.
|
|
87
|
+
*/
|
|
88
|
+
create(input: PurchaseReceiptGroupCreateInput, ctx: TCtx): Promise<{
|
|
89
|
+
_id: unknown;
|
|
90
|
+
}>;
|
|
91
|
+
executeAction(groupId: string, action: string, payload: unknown, ctx: TCtx): Promise<unknown>;
|
|
92
|
+
};
|
|
93
|
+
quant: {
|
|
94
|
+
getAvailability(query: {
|
|
95
|
+
skuRef: string;
|
|
96
|
+
locationId: string;
|
|
97
|
+
}, ctx: TCtx): Promise<{
|
|
98
|
+
breakdowns?: Array<{
|
|
99
|
+
unitCostScaled?: number | undefined;
|
|
100
|
+
}> | undefined;
|
|
101
|
+
}>;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Per-receipt destination resolution.
|
|
107
|
+
*
|
|
108
|
+
* A FACTORY, not a bare function, because resolution is cached for the duration of one
|
|
109
|
+
* receipt — a 40-line PO into one location must not issue 40 identical lookups. The host
|
|
110
|
+
* decides what the cache is.
|
|
111
|
+
*/
|
|
112
|
+
interface PurchaseLocationResolverFactory<TCtx extends PurchaseReceiptScope = PurchaseReceiptContext> {
|
|
113
|
+
/** Fresh resolver (and cache) for one receipt run. */
|
|
114
|
+
create(ctx: TCtx): {
|
|
115
|
+
/** Location CODE for a line's destination, or throw. */
|
|
116
|
+
resolve(destinationLocationId: unknown): Promise<string>;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Is this error "that location cannot be resolved" rather than a real fault?
|
|
120
|
+
*
|
|
121
|
+
* Load-bearing: an unresolvable destination is COLLECTED per line and reported, while
|
|
122
|
+
* anything else must propagate. Classifying by error TYPE is the host's job because the
|
|
123
|
+
* host owns the resolver — a message-substring check here would silently start swallowing
|
|
124
|
+
* genuine faults the day a message changes.
|
|
125
|
+
*/
|
|
126
|
+
isUnresolvable(error: unknown): boolean;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Denormalized product cost snapshot, in integer MINOR units.
|
|
130
|
+
*
|
|
131
|
+
* Display/reporting only — it never re-enters the warehouse's valuation, which is why a
|
|
132
|
+
* one-way write is the whole contract.
|
|
133
|
+
*/
|
|
134
|
+
interface PurchaseCostSnapshotPort {
|
|
135
|
+
set(productId: string, variantSku: string | null, costMinor: number): Promise<void>;
|
|
136
|
+
}
|
|
137
|
+
interface PurchaseStockReceiptDeps<TCtx extends PurchaseReceiptScope = PurchaseReceiptContext> {
|
|
138
|
+
/** Getter, not a handle — nothing may allocate the engine during composition. */
|
|
139
|
+
warehouse: () => PurchaseReceiptWarehouse<TCtx>;
|
|
140
|
+
locations: PurchaseLocationResolverFactory<TCtx>;
|
|
141
|
+
costSnapshot: PurchaseCostSnapshotPort;
|
|
142
|
+
/** Branch + actor → WMS scope. The host owns the `organizationId = branchId` mapping. */
|
|
143
|
+
/** The host's OWN context type flows through unchanged — see `PurchaseReceiptContext`. */
|
|
144
|
+
buildContext(branchId: string, actorId: string): TCtx;
|
|
145
|
+
/**
|
|
146
|
+
* First-receipt-per-branch provisioning (warehouse node + locations). A no-op is a valid
|
|
147
|
+
* implementation for a deployment that provisions ahead of time.
|
|
148
|
+
*/
|
|
149
|
+
ensureBranchReady(organizationId: string): Promise<void>;
|
|
150
|
+
/** The host's `productId` (+ variant) → `skuRef` convention. */
|
|
151
|
+
skuRef(productId: string, variantSku: string | null): string;
|
|
152
|
+
/** Counterparty location goods arrive FROM — unbounded by design. */
|
|
153
|
+
vendorLocationId: string;
|
|
154
|
+
}
|
|
155
|
+
/** One line that could not be booked, with the reason. Reported, never thrown. */
|
|
156
|
+
interface PurchaseReceiptLineError {
|
|
157
|
+
productId: string;
|
|
158
|
+
variantSku?: string | null;
|
|
159
|
+
error: string;
|
|
160
|
+
}
|
|
161
|
+
interface ReceivePurchaseIntoStockInput {
|
|
162
|
+
purchase: PurchaseOrderDocument;
|
|
163
|
+
/** Vendor label for the move group's provenance metadata. */
|
|
164
|
+
supplierName?: string | undefined;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
export { PurchaseCostSnapshotPort, PurchaseLocationResolverFactory, PurchaseReceiptContext, PurchaseReceiptGroupCreateInput, PurchaseReceiptLineError, PurchaseReceiptScope, PurchaseReceiptWarehouse, PurchaseStockReceiptDeps, ReceivePurchaseIntoStockInput };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { PurchaseEngine } from "@classytic/purchase/engine";
|
|
2
|
+
import { IPurchaseOrder, IStatusHistory } from "@classytic/purchase";
|
|
3
|
+
import { Repository } from "@classytic/mongokit";
|
|
4
|
+
import { EngineRef } from "@spinekit/kit/engine-slot";
|
|
5
|
+
import { ClientSession } from "mongoose";
|
|
6
|
+
//#region src/repositories/purchase-order.repository.d.ts
|
|
7
|
+
declare class PurchaseOrderRepository extends Repository<IPurchaseOrder> {
|
|
8
|
+
constructor(engine: EngineRef<PurchaseEngine>);
|
|
9
|
+
/**
|
|
10
|
+
* Inject `populate: [supplier]` into HTTP-shaped read paths so list /
|
|
11
|
+
* detail responses ship a populated supplier object instead of the bare
|
|
12
|
+
* ObjectId ref.
|
|
13
|
+
*
|
|
14
|
+
* Wired ONLY on `getAll` / `getOne` / `getByQuery` — these are the paths
|
|
15
|
+
* Arc's BaseController takes for `GET /` (→ getAll) and `GET /:id`
|
|
16
|
+
* (→ getOne with compound filter via AccessControl.fetchDetailed). The
|
|
17
|
+
* `getById` path is left bare on purpose: internal callers in the
|
|
18
|
+
* `actions/` folder (approve / receive / pay / cancel / update-draft)
|
|
19
|
+
* call `getPurchaseOrderRepository().getById(id, { lean: true })` and rely
|
|
20
|
+
* on `purchase.supplier` being a bare ObjectId for transaction tagging
|
|
21
|
+
* and partner ref lookup. Auto-populating that path would break those
|
|
22
|
+
* actions silently — `String(populatedDoc)` returns `'[object Object]'`.
|
|
23
|
+
*
|
|
24
|
+
* Mongokit's getAll/findAll picks up `context.populate` (line ~440 + ~554
|
|
25
|
+
* of mongokit Repository.ts). `context.populateOptions` is NOT in that
|
|
26
|
+
* chain — only `params.populateOptions` and `options.populateOptions`
|
|
27
|
+
* are honored, neither of which a `before:*` hook can touch — so the
|
|
28
|
+
* hook MUST set `context.populate`.
|
|
29
|
+
*/
|
|
30
|
+
private _setupReadHooks;
|
|
31
|
+
appendStatus(id: string, statusEntry: IStatusHistory, updates?: Record<string, unknown>, options?: {
|
|
32
|
+
session?: ClientSession | null;
|
|
33
|
+
}): Promise<IPurchaseOrder | null>;
|
|
34
|
+
recordPayment(id: string, transactionId: string, paymentUpdate?: Record<string, unknown>, options?: {
|
|
35
|
+
session?: ClientSession | null;
|
|
36
|
+
}): Promise<IPurchaseOrder | null>;
|
|
37
|
+
}
|
|
38
|
+
declare function createPurchaseOrderRepository(engine: EngineRef<PurchaseEngine>): PurchaseOrderRepository;
|
|
39
|
+
//#endregion
|
|
40
|
+
export { PurchaseOrderRepository, createPurchaseOrderRepository };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Repository, requireField, validationChainPlugin } from "@classytic/mongokit";
|
|
2
|
+
import { resolveEngineRef } from "@spinekit/kit/engine-slot";
|
|
3
|
+
//#region src/repositories/purchase-order.repository.ts
|
|
4
|
+
/**
|
|
5
|
+
* Populate-aware PO repository, parameterised by the engine.
|
|
6
|
+
*
|
|
7
|
+
* Moved out of be-prod: the only host tie was a `getPurchaseEngine()` singleton import.
|
|
8
|
+
* The supplier-populate policy below is not deployment-specific — any deployment that
|
|
9
|
+
* lists purchase orders needs the supplier display fields, and the `model: 'Supplier'`
|
|
10
|
+
* hint is required by the KERNEL's schema shape, not by one company's data.
|
|
11
|
+
*/
|
|
12
|
+
function getPurchaseModel(engine) {
|
|
13
|
+
const resolved = resolveEngineRef(engine);
|
|
14
|
+
if (resolved instanceof Promise) throw new Error("[spine-purchase] repository requires a synchronously-resolvable engine");
|
|
15
|
+
return resolved.models.PurchaseOrder;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Always-on populate for read paths. Display surfaces (list table, detail
|
|
19
|
+
* sheet, print) all need `supplier.name` + `supplier.code`; without populate
|
|
20
|
+
* the wire payload only carries the bare ObjectId and the FE has to make a
|
|
21
|
+
* second `/suppliers` call to resolve. Project just the two display fields
|
|
22
|
+
* so we don't fan out the whole supplier doc on every PO row.
|
|
23
|
+
*
|
|
24
|
+
* `model: 'Supplier'` is explicit because the package's PurchaseOrder schema
|
|
25
|
+
* stores `supplier` as a bare ObjectId without a `ref:` (the package can't
|
|
26
|
+
* import host-owned models). Mongoose needs the model name to resolve the
|
|
27
|
+
* populate target.
|
|
28
|
+
*/
|
|
29
|
+
const SUPPLIER_DISPLAY_POPULATE = {
|
|
30
|
+
path: "supplier",
|
|
31
|
+
model: "Supplier",
|
|
32
|
+
select: "name code"
|
|
33
|
+
};
|
|
34
|
+
var PurchaseOrderRepository = class extends Repository {
|
|
35
|
+
constructor(engine) {
|
|
36
|
+
super(getPurchaseModel(engine), [validationChainPlugin([requireField("invoiceNumber", ["create"]), requireField("branch", ["create"])])], {
|
|
37
|
+
defaultLimit: 20,
|
|
38
|
+
maxLimit: 100
|
|
39
|
+
});
|
|
40
|
+
this._setupReadHooks();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Inject `populate: [supplier]` into HTTP-shaped read paths so list /
|
|
44
|
+
* detail responses ship a populated supplier object instead of the bare
|
|
45
|
+
* ObjectId ref.
|
|
46
|
+
*
|
|
47
|
+
* Wired ONLY on `getAll` / `getOne` / `getByQuery` — these are the paths
|
|
48
|
+
* Arc's BaseController takes for `GET /` (→ getAll) and `GET /:id`
|
|
49
|
+
* (→ getOne with compound filter via AccessControl.fetchDetailed). The
|
|
50
|
+
* `getById` path is left bare on purpose: internal callers in the
|
|
51
|
+
* `actions/` folder (approve / receive / pay / cancel / update-draft)
|
|
52
|
+
* call `getPurchaseOrderRepository().getById(id, { lean: true })` and rely
|
|
53
|
+
* on `purchase.supplier` being a bare ObjectId for transaction tagging
|
|
54
|
+
* and partner ref lookup. Auto-populating that path would break those
|
|
55
|
+
* actions silently — `String(populatedDoc)` returns `'[object Object]'`.
|
|
56
|
+
*
|
|
57
|
+
* Mongokit's getAll/findAll picks up `context.populate` (line ~440 + ~554
|
|
58
|
+
* of mongokit Repository.ts). `context.populateOptions` is NOT in that
|
|
59
|
+
* chain — only `params.populateOptions` and `options.populateOptions`
|
|
60
|
+
* are honored, neither of which a `before:*` hook can touch — so the
|
|
61
|
+
* hook MUST set `context.populate`.
|
|
62
|
+
*/
|
|
63
|
+
_setupReadHooks() {
|
|
64
|
+
const inject = (payload) => {
|
|
65
|
+
const ctx = payload;
|
|
66
|
+
if (ctx.populate) return;
|
|
67
|
+
ctx.populate = [SUPPLIER_DISPLAY_POPULATE];
|
|
68
|
+
};
|
|
69
|
+
this.on("before:getAll", inject);
|
|
70
|
+
this.on("before:getByQuery", inject);
|
|
71
|
+
this.on("before:getOne", inject);
|
|
72
|
+
}
|
|
73
|
+
async appendStatus(id, statusEntry, updates = {}, options = {}) {
|
|
74
|
+
const { session = null } = options;
|
|
75
|
+
return this.Model.findByIdAndUpdate(id, {
|
|
76
|
+
...updates,
|
|
77
|
+
$push: { statusHistory: statusEntry }
|
|
78
|
+
}, {
|
|
79
|
+
returnDocument: "after",
|
|
80
|
+
...session ? { session } : {}
|
|
81
|
+
}).lean();
|
|
82
|
+
}
|
|
83
|
+
async recordPayment(id, transactionId, paymentUpdate = {}, options = {}) {
|
|
84
|
+
const { session = null } = options;
|
|
85
|
+
return this.Model.findByIdAndUpdate(id, {
|
|
86
|
+
$push: { transactionIds: transactionId },
|
|
87
|
+
...paymentUpdate
|
|
88
|
+
}, {
|
|
89
|
+
returnDocument: "after",
|
|
90
|
+
...session ? { session } : {}
|
|
91
|
+
}).lean();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Memoized factory, one instance per engine reference.
|
|
96
|
+
*
|
|
97
|
+
* A module-level singleton would bind the FIRST engine it ever saw — fine in a single
|
|
98
|
+
* host, wrong for a package two deployments (or two tests) share in one process.
|
|
99
|
+
*/
|
|
100
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
101
|
+
function createPurchaseOrderRepository(engine) {
|
|
102
|
+
const key = typeof engine === "function" ? engine : engine;
|
|
103
|
+
let repo = cache.get(key);
|
|
104
|
+
if (!repo) {
|
|
105
|
+
repo = new PurchaseOrderRepository(engine);
|
|
106
|
+
cache.set(key, repo);
|
|
107
|
+
}
|
|
108
|
+
return repo;
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
export { PurchaseOrderRepository, createPurchaseOrderRepository };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { n as PurchasePermissions } from "../../types-T9gsXOf_.mjs";
|
|
2
|
+
import { PurchaseEngine } from "@classytic/purchase/engine";
|
|
3
|
+
import { AnyRecord, ResourceSeams } from "@classytic/arc";
|
|
4
|
+
import { RequestWithExtras } from "@classytic/arc/types";
|
|
5
|
+
//#region src/resources/purchase-order/purchase-order.resource.d.ts
|
|
6
|
+
declare function createPurchaseOrderResource(args: {
|
|
7
|
+
engine: PurchaseEngine;
|
|
8
|
+
permissions: PurchasePermissions;
|
|
9
|
+
prefix?: string;
|
|
10
|
+
extraActions?: ResourceSeams['actions'];
|
|
11
|
+
extraRoutes?: ResourceSeams['routes'];
|
|
12
|
+
/**
|
|
13
|
+
* Mongoose populate specs injected into HTTP READ paths only (list +
|
|
14
|
+
* detail) — display joins like `{ path: 'supplier', model: 'Supplier',
|
|
15
|
+
* select: 'name code' }`. Kernel verbs and internal `getById` callers
|
|
16
|
+
* stay bare on purpose: domain code relies on refs being bare ObjectIds
|
|
17
|
+
* (`String(order.supplier)` — a populated doc stringifies to
|
|
18
|
+
* `'[object Object]'`).
|
|
19
|
+
*/
|
|
20
|
+
readPopulate?: unknown[];
|
|
21
|
+
}): import("@classytic/arc").ResourceDefinition<AnyRecord> & {
|
|
22
|
+
readonly actions: {
|
|
23
|
+
approve: {
|
|
24
|
+
permissions: import("@spinekit/kit/permissions").PermissionGate;
|
|
25
|
+
handler: (id: string, _d: unknown, req: RequestWithExtras) => Promise<import("mongoose").Document<unknown, {}, import("@classytic/purchase").IPurchaseOrder, {}, import("mongoose").DefaultSchemaOptions> & import("@classytic/purchase").IPurchaseOrder & {
|
|
26
|
+
_id: import("mongoose").Types.ObjectId;
|
|
27
|
+
} & {
|
|
28
|
+
__v: number;
|
|
29
|
+
} & {
|
|
30
|
+
id: string;
|
|
31
|
+
}>;
|
|
32
|
+
};
|
|
33
|
+
receive: {
|
|
34
|
+
permissions: import("@spinekit/kit/permissions").PermissionGate;
|
|
35
|
+
handler: (id: string, _d: unknown, req: RequestWithExtras) => Promise<import("mongoose").Document<unknown, {}, import("@classytic/purchase").IPurchaseOrder, {}, import("mongoose").DefaultSchemaOptions> & import("@classytic/purchase").IPurchaseOrder & {
|
|
36
|
+
_id: import("mongoose").Types.ObjectId;
|
|
37
|
+
} & {
|
|
38
|
+
__v: number;
|
|
39
|
+
} & {
|
|
40
|
+
id: string;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
cancel: {
|
|
44
|
+
permissions: import("@spinekit/kit/permissions").PermissionGate;
|
|
45
|
+
handler: (id: string, data: unknown, req: RequestWithExtras) => Promise<import("mongoose").Document<unknown, {}, import("@classytic/purchase").IPurchaseOrder, {}, import("mongoose").DefaultSchemaOptions> & import("@classytic/purchase").IPurchaseOrder & {
|
|
46
|
+
_id: import("mongoose").Types.ObjectId;
|
|
47
|
+
} & {
|
|
48
|
+
__v: number;
|
|
49
|
+
} & {
|
|
50
|
+
id: string;
|
|
51
|
+
}>;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
export { createPurchaseOrderResource };
|