@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +75 -0
  3. package/README.md +40 -0
  4. package/dist/bridges.d.mts +167 -0
  5. package/dist/bridges.mjs +151 -0
  6. package/dist/entity-model-D-03ovSg.mjs +23 -0
  7. package/dist/index.d.mts +5 -0
  8. package/dist/index.mjs +140 -0
  9. package/dist/lifecycle/purchase-lifecycle.d.mts +17 -0
  10. package/dist/lifecycle/purchase-lifecycle.mjs +120 -0
  11. package/dist/lifecycle/purchase-lifecycle.types.d.mts +32 -0
  12. package/dist/lifecycle/purchase-lifecycle.types.mjs +1 -0
  13. package/dist/payment/purchase-payment.application.d.mts +8 -0
  14. package/dist/payment/purchase-payment.application.mjs +154 -0
  15. package/dist/payment/purchase-payment.tax.d.mts +30 -0
  16. package/dist/payment/purchase-payment.tax.mjs +37 -0
  17. package/dist/payment/purchase-payment.types.d.mts +2 -0
  18. package/dist/payment/purchase-payment.types.mjs +1 -0
  19. package/dist/purchase-payment.types-hVKAeW3G.d.mts +153 -0
  20. package/dist/receipt/purchase-stock-receipt.d.mts +12 -0
  21. package/dist/receipt/purchase-stock-receipt.mjs +225 -0
  22. package/dist/receipt/purchase-stock-receipt.types.d.mts +167 -0
  23. package/dist/receipt/purchase-stock-receipt.types.mjs +1 -0
  24. package/dist/repositories/purchase-order.repository.d.mts +40 -0
  25. package/dist/repositories/purchase-order.repository.mjs +111 -0
  26. package/dist/resources/purchase-order/purchase-order.resource.d.mts +56 -0
  27. package/dist/resources/purchase-order/purchase-order.resource.mjs +193 -0
  28. package/dist/resources/supplier/supplier.model.d.mts +2 -0
  29. package/dist/resources/supplier/supplier.model.mjs +178 -0
  30. package/dist/resources/supplier/supplier.repository.d.mts +20 -0
  31. package/dist/resources/supplier/supplier.repository.mjs +62 -0
  32. package/dist/resources/supplier/supplier.resource.d.mts +34 -0
  33. package/dist/resources/supplier/supplier.resource.mjs +129 -0
  34. package/dist/resources/supplier/supplier.types.d.mts +2 -0
  35. package/dist/resources/supplier/supplier.types.mjs +1 -0
  36. package/dist/supplier.model-BcAfgyHu.d.mts +88 -0
  37. package/dist/types-T9gsXOf_.d.mts +127 -0
  38. 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 };