@blamejs/blamejs-shop 0.0.64 → 0.0.66

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.
@@ -0,0 +1,923 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.purchaseOrders
4
+ * @title Purchase orders — operator-facing record of goods ordered
5
+ * FROM a vendor / supplier with receipt cascade into inventory
6
+ *
7
+ * @intro
8
+ * A PO is the operator's record of having placed an order with a
9
+ * vendor: which SKUs, how many, at what cost, and the lifecycle
10
+ * beat from draft through to the goods landing and the PO closing.
11
+ * The PO is upstream of `inventoryReceive`: when goods arrive
12
+ * against a PO, `recordPartialReceipt` composes the injected
13
+ * `inventoryReceive.draft + .apply` so the catalog's inventory
14
+ * table gets the restock alongside the PO line's
15
+ * quantity_received counter advancing. The catalog therefore
16
+ * stays the single owner of stock_on_hand mutation; the PO owns
17
+ * the upstream-procurement audit trail.
18
+ *
19
+ * FSM:
20
+ *
21
+ * draft -> submitted -> confirmed -> partially_received -> received -> closed
22
+ * |
23
+ * +-> received (full receipt skips partial)
24
+ *
25
+ * draft|submitted -> cancelled (operator pulls the PO)
26
+ *
27
+ * (confirmed and later are NOT cancellable through this surface —
28
+ * once the vendor has accepted, a cancellation becomes a
29
+ * separate commercial conversation the operator records via
30
+ * notes + closePO with a custom workflow upstream.)
31
+ *
32
+ * Composes:
33
+ * - vendors — validates `vendor_slug` exists + is not
34
+ * archived before creating a draft against
35
+ * it. Injected as `opts.vendors`. Optional
36
+ * (when absent the primitive skips the
37
+ * vendor-existence check; tests that don't
38
+ * wire vendors still run).
39
+ * - inventoryReceive — composed at recordPartialReceipt time:
40
+ * the per-line `quantity_received` counter
41
+ * advances AND the catalog's inventory
42
+ * table receives the restock via
43
+ * inventoryReceive.draft + .apply.
44
+ * Injected as `opts.inventoryReceive`.
45
+ * Optional — when absent, the PO line
46
+ * counter still advances but the
47
+ * inventory restock is the operator's
48
+ * responsibility (they call
49
+ * inventory-receive.draft directly).
50
+ * - b.uuid.v7 — PO + line PKs (sortable; B-tree locality)
51
+ * - b.guardUuid — strict UUID validation on every po_id
52
+ * read
53
+ *
54
+ * Surface:
55
+ * createDraft({ vendor_slug, lines: [{ sku, quantity,
56
+ * unit_cost_minor, currency? }], notes?,
57
+ * expected_delivery? })
58
+ * submitToVendor({ po_id, submitted_by? })
59
+ * confirmByVendor({ po_id, confirmed_at, vendor_reference? })
60
+ * recordPartialReceipt({ po_id, received_lines:
61
+ * [{ sku, quantity_received, unit_cost_minor? }],
62
+ * reference?, received_by? })
63
+ * closePO({ po_id }) — requires every line fully
64
+ * received (status === 'received')
65
+ * cancelPO({ po_id, reason }) — only from draft | submitted
66
+ * getPO(po_id) / listPOs({ status?, vendor_slug? })
67
+ * linesForPO(po_id)
68
+ * update(po_id, patch) — only on draft status
69
+ *
70
+ * Storage: `migrations-d1/0099_purchase_orders.sql` — two tables,
71
+ * `purchase_orders` + `purchase_order_lines`. ON DELETE CASCADE
72
+ * from po -> lines.
73
+ *
74
+ * @primitive purchaseOrders
75
+ * @related shop.vendors, shop.inventoryReceive, b.uuid, b.guardUuid
76
+ */
77
+
78
+ var MAX_NOTES_LEN = 4000;
79
+ var MAX_VENDOR_REF_LEN = 256;
80
+ var MAX_SUBMITTED_BY_LEN = 256;
81
+ var MAX_RECEIVED_BY_LEN = 256;
82
+ var MAX_REFERENCE_LEN = 128;
83
+ var MAX_CANCEL_REASON_LEN = 280;
84
+ var MAX_LINES = 1000;
85
+ var MAX_QUANTITY = 1000000; // 1M units per line sanity cap
86
+ var MAX_UNIT_COST_MINOR = 100000000000; // 1e11 minor units sanity cap
87
+ var MAX_SLUG_LEN = 64;
88
+ var MAX_SKU_LEN = 128;
89
+
90
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
91
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
92
+ var CURRENCY_RE = /^[A-Z]{3}$/;
93
+ var REFERENCE_RE = /^[A-Za-z0-9][A-Za-z0-9._\/ -]{0,127}$/;
94
+
95
+ // Control bytes + zero-width / direction-override family — same shape
96
+ // as vendors. Operator-rendered text fields refuse these to keep the
97
+ // downstream dashboard / printout safe from header-injection +
98
+ // visual-spoofing attacks.
99
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
100
+ var ZERO_WIDTH_RE = new RegExp(
101
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
102
+ );
103
+
104
+ var PO_STATUSES = Object.freeze([
105
+ "draft", "submitted", "confirmed", "partially_received",
106
+ "received", "closed", "cancelled",
107
+ ]);
108
+
109
+ // Mutable columns for `update(po_id, patch)`. Only draft POs may
110
+ // mutate; once submitted, the PO is the vendor-facing contract and
111
+ // changes route through a separate revision workflow the operator
112
+ // owns. vendor_slug is immutable post-create — a different vendor
113
+ // is a new PO.
114
+ var ALLOWED_UPDATE_COLUMNS = Object.freeze([
115
+ "notes", "expected_delivery", "lines",
116
+ ]);
117
+
118
+ // Lazy framework handle — matches the pattern every other shop
119
+ // primitive uses; avoids the require cycle that would arise from
120
+ // importing `./index` at module-eval time.
121
+ var bShop;
122
+ function _b() {
123
+ if (!bShop) bShop = require("./index");
124
+ return bShop.framework;
125
+ }
126
+
127
+ // ---- validators ---------------------------------------------------------
128
+
129
+ function _id(s, label) {
130
+ try {
131
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
132
+ } catch (e) {
133
+ throw new TypeError("purchase-orders: " + label + " — " + (e && e.message || "invalid UUID"));
134
+ }
135
+ }
136
+
137
+ function _slug(s, label) {
138
+ if (typeof s !== "string" || !s.length) {
139
+ throw new TypeError("purchase-orders: " + label + " must be a non-empty string");
140
+ }
141
+ if (s.length > MAX_SLUG_LEN) {
142
+ throw new TypeError("purchase-orders: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
143
+ }
144
+ if (!SLUG_RE.test(s)) {
145
+ throw new TypeError("purchase-orders: " + label + " must be lowercase alnum + dash, no leading/trailing dash");
146
+ }
147
+ return s;
148
+ }
149
+
150
+ function _sku(s) {
151
+ if (typeof s !== "string" || !s.length) {
152
+ throw new TypeError("purchase-orders: sku must be a non-empty string");
153
+ }
154
+ if (s.length > MAX_SKU_LEN) {
155
+ throw new TypeError("purchase-orders: sku must be <= " + MAX_SKU_LEN + " characters");
156
+ }
157
+ if (!SKU_RE.test(s)) {
158
+ throw new TypeError("purchase-orders: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/");
159
+ }
160
+ return s;
161
+ }
162
+
163
+ function _currency(s) {
164
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
165
+ throw new TypeError("purchase-orders: currency must be a 3-letter uppercase ISO-4217 code");
166
+ }
167
+ return s;
168
+ }
169
+
170
+ function _reference(s, label) {
171
+ if (typeof s !== "string" || !s.length || s.length > MAX_REFERENCE_LEN || !REFERENCE_RE.test(s)) {
172
+ throw new TypeError("purchase-orders: " + (label || "reference") +
173
+ " must be a non-empty string <= " + MAX_REFERENCE_LEN +
174
+ " chars matching /^[A-Za-z0-9][A-Za-z0-9._\\/ -]*$/");
175
+ }
176
+ return s;
177
+ }
178
+
179
+ function _positiveInt(n, label) {
180
+ if (!Number.isInteger(n) || n <= 0) {
181
+ throw new TypeError("purchase-orders: " + label + " must be a positive integer");
182
+ }
183
+ }
184
+
185
+ function _nonNegInt(n, label) {
186
+ if (!Number.isInteger(n) || n < 0) {
187
+ throw new TypeError("purchase-orders: " + label + " must be a non-negative integer");
188
+ }
189
+ }
190
+
191
+ function _quantity(n, label) {
192
+ _positiveInt(n, label);
193
+ if (n > MAX_QUANTITY) {
194
+ throw new TypeError("purchase-orders: " + label + " must be <= " + MAX_QUANTITY);
195
+ }
196
+ }
197
+
198
+ function _unitCost(n, label) {
199
+ _nonNegInt(n, label);
200
+ if (n > MAX_UNIT_COST_MINOR) {
201
+ throw new TypeError("purchase-orders: " + label + " must be <= " + MAX_UNIT_COST_MINOR);
202
+ }
203
+ }
204
+
205
+ function _ts(n, label) {
206
+ if (n == null) return null;
207
+ if (!Number.isInteger(n) || n < 0) {
208
+ throw new TypeError("purchase-orders: " + label + " must be a non-negative integer (epoch ms)");
209
+ }
210
+ return n;
211
+ }
212
+
213
+ function _shortText(s, label, max) {
214
+ if (s == null) return "";
215
+ if (typeof s !== "string") {
216
+ throw new TypeError("purchase-orders: " + label + " must be a string");
217
+ }
218
+ if (s.length > max) {
219
+ throw new TypeError("purchase-orders: " + label + " must be <= " + max + " characters");
220
+ }
221
+ if (CONTROL_BYTE_RE.test(s)) {
222
+ throw new TypeError("purchase-orders: " + label + " contains control bytes");
223
+ }
224
+ if (ZERO_WIDTH_RE.test(s)) {
225
+ throw new TypeError("purchase-orders: " + label + " contains zero-width / direction-override bytes");
226
+ }
227
+ return s;
228
+ }
229
+
230
+ function _status(s) {
231
+ if (typeof s !== "string" || PO_STATUSES.indexOf(s) === -1) {
232
+ throw new TypeError("purchase-orders: status must be one of " + PO_STATUSES.join(", "));
233
+ }
234
+ return s;
235
+ }
236
+
237
+ function _now() { return Date.now(); }
238
+
239
+ // Validate + normalize the `lines` array. Returns an array of
240
+ // `{ sku, quantity, unit_cost_minor, currency }` with defaults
241
+ // applied. Refuses duplicate SKUs — the same SKU twice on a PO is a
242
+ // merge-up-front concern, not a per-line decision.
243
+ function _validateLines(lines, label) {
244
+ if (!Array.isArray(lines) || lines.length === 0) {
245
+ throw new TypeError("purchase-orders: " + label + " must be a non-empty array");
246
+ }
247
+ if (lines.length > MAX_LINES) {
248
+ throw new TypeError("purchase-orders: " + label + " must contain <= " + MAX_LINES + " entries");
249
+ }
250
+ var seen = Object.create(null);
251
+ var normalized = [];
252
+ for (var i = 0; i < lines.length; i += 1) {
253
+ var l = lines[i];
254
+ if (!l || typeof l !== "object") {
255
+ throw new TypeError("purchase-orders: " + label + "[" + i + "] must be an object");
256
+ }
257
+ var sku = _sku(l.sku);
258
+ _quantity(l.quantity, label + "[" + i + "].quantity");
259
+ _unitCost(l.unit_cost_minor, label + "[" + i + "].unit_cost_minor");
260
+ var currency = l.currency == null ? "USD" : _currency(l.currency);
261
+ if (seen[sku]) {
262
+ throw new TypeError("purchase-orders: duplicate sku " + JSON.stringify(sku) + " in " + label);
263
+ }
264
+ seen[sku] = true;
265
+ normalized.push({
266
+ sku: sku,
267
+ quantity: l.quantity,
268
+ unit_cost_minor: l.unit_cost_minor,
269
+ currency: currency,
270
+ });
271
+ }
272
+ return normalized;
273
+ }
274
+
275
+ // ---- row hydration ------------------------------------------------------
276
+
277
+ function _hydratePO(row) {
278
+ if (!row) return null;
279
+ return {
280
+ id: row.id,
281
+ vendor_slug: row.vendor_slug,
282
+ status: row.status,
283
+ expected_delivery: row.expected_delivery == null ? null : Number(row.expected_delivery),
284
+ vendor_reference: row.vendor_reference == null ? null : row.vendor_reference,
285
+ notes: row.notes == null ? "" : row.notes,
286
+ submitted_at: row.submitted_at == null ? null : Number(row.submitted_at),
287
+ submitted_by: row.submitted_by == null ? null : row.submitted_by,
288
+ confirmed_at: row.confirmed_at == null ? null : Number(row.confirmed_at),
289
+ closed_at: row.closed_at == null ? null : Number(row.closed_at),
290
+ cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
291
+ cancel_reason: row.cancel_reason == null ? null : row.cancel_reason,
292
+ created_at: Number(row.created_at),
293
+ updated_at: Number(row.updated_at),
294
+ };
295
+ }
296
+
297
+ function _hydrateLine(row) {
298
+ return {
299
+ id: row.id,
300
+ po_id: row.po_id,
301
+ sku: row.sku,
302
+ quantity_ordered: Number(row.quantity_ordered),
303
+ quantity_received: Number(row.quantity_received),
304
+ unit_cost_minor: Number(row.unit_cost_minor),
305
+ currency: row.currency,
306
+ created_at: Number(row.created_at),
307
+ updated_at: Number(row.updated_at),
308
+ };
309
+ }
310
+
311
+ // ---- factory ------------------------------------------------------------
312
+
313
+ function create(opts) {
314
+ opts = opts || {};
315
+ var query = opts.query;
316
+ if (!query) {
317
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
318
+ }
319
+ // The vendors handle is optional — when wired, createDraft refuses
320
+ // to open a PO against an unknown or archived vendor. When absent,
321
+ // the operator has chosen to manage vendor existence out of band
322
+ // (tests that don't load the vendors migration still exercise the
323
+ // PO surface).
324
+ var vendorsHandle = opts.vendors || null;
325
+ if (vendorsHandle && (typeof vendorsHandle.getVendor !== "function")) {
326
+ throw new TypeError("purchase-orders.create: opts.vendors must expose getVendor(slug) when provided");
327
+ }
328
+ // The inventoryReceive handle is optional too. When wired,
329
+ // recordPartialReceipt composes inventoryReceive.draft + .apply so
330
+ // the catalog's inventory table gets the restock alongside the PO
331
+ // counter advancing. When absent, the PO counter still advances
332
+ // but the inventory restock is the operator's responsibility.
333
+ var receiveHandle = opts.inventoryReceive || null;
334
+ if (receiveHandle && (typeof receiveHandle.draft !== "function" ||
335
+ typeof receiveHandle.apply !== "function")) {
336
+ throw new TypeError("purchase-orders.create: opts.inventoryReceive must expose draft + apply when provided");
337
+ }
338
+
339
+ async function _getPORaw(id) {
340
+ var r = await query("SELECT * FROM purchase_orders WHERE id = ?1", [id]);
341
+ return r.rows[0] || null;
342
+ }
343
+
344
+ async function _getLinesRaw(poId) {
345
+ var r = await query(
346
+ "SELECT * FROM purchase_order_lines WHERE po_id = ?1 ORDER BY sku ASC",
347
+ [poId],
348
+ );
349
+ return r.rows;
350
+ }
351
+
352
+ async function _hydrated(id) {
353
+ var po = await _getPORaw(id);
354
+ if (!po) return null;
355
+ var lines = await _getLinesRaw(id);
356
+ var out = _hydratePO(po);
357
+ out.lines = lines.map(_hydrateLine);
358
+ return out;
359
+ }
360
+
361
+ // Decide the receipt-driven status transition. If every line is
362
+ // fully received (received >= ordered for all lines), the PO moves
363
+ // to 'received'. If some lines have any receipts, it's
364
+ // 'partially_received'. Else the prior status holds.
365
+ function _statusAfterReceipt(lines, priorStatus) {
366
+ var anyReceived = false;
367
+ var allFull = true;
368
+ for (var i = 0; i < lines.length; i += 1) {
369
+ var got = Number(lines[i].quantity_received || 0);
370
+ var ord = Number(lines[i].quantity_ordered);
371
+ if (got > 0) anyReceived = true;
372
+ if (got < ord) allFull = false;
373
+ }
374
+ if (allFull) return "received";
375
+ if (anyReceived) return "partially_received";
376
+ return priorStatus;
377
+ }
378
+
379
+ return {
380
+ PO_STATUSES: PO_STATUSES.slice(),
381
+ MAX_NOTES_LEN: MAX_NOTES_LEN,
382
+ MAX_VENDOR_REF_LEN: MAX_VENDOR_REF_LEN,
383
+ MAX_SUBMITTED_BY_LEN: MAX_SUBMITTED_BY_LEN,
384
+ MAX_RECEIVED_BY_LEN: MAX_RECEIVED_BY_LEN,
385
+ MAX_REFERENCE_LEN: MAX_REFERENCE_LEN,
386
+ MAX_CANCEL_REASON_LEN: MAX_CANCEL_REASON_LEN,
387
+ MAX_LINES: MAX_LINES,
388
+ MAX_QUANTITY: MAX_QUANTITY,
389
+ MAX_UNIT_COST_MINOR: MAX_UNIT_COST_MINOR,
390
+
391
+ // Open a draft PO. Validates the vendor exists + is not archived
392
+ // when the vendors handle is wired. The lines array is captured
393
+ // with per-SKU quantity + unit_cost + currency. No vendor
394
+ // notification side-effects fire — that's submitToVendor's job.
395
+ createDraft: async function (input) {
396
+ if (!input || typeof input !== "object") {
397
+ throw new TypeError("purchase-orders.createDraft: input object required");
398
+ }
399
+ var vendorSlug = _slug(input.vendor_slug, "vendor_slug");
400
+ var lines = _validateLines(input.lines, "lines");
401
+ var notes = _shortText(input.notes, "notes", MAX_NOTES_LEN);
402
+ var expected = _ts(input.expected_delivery, "expected_delivery");
403
+
404
+ if (vendorsHandle) {
405
+ var v = await vendorsHandle.getVendor(vendorSlug);
406
+ if (!v) {
407
+ var miss = new Error("purchase-orders.createDraft: vendor " + JSON.stringify(vendorSlug) + " not found");
408
+ miss.code = "PO_VENDOR_NOT_FOUND";
409
+ throw miss;
410
+ }
411
+ if (v.status === "archived") {
412
+ var arch = new Error("purchase-orders.createDraft: vendor " + JSON.stringify(vendorSlug) + " is archived");
413
+ arch.code = "PO_VENDOR_ARCHIVED";
414
+ throw arch;
415
+ }
416
+ }
417
+
418
+ var id = _b().uuid.v7();
419
+ var ts = _now();
420
+
421
+ try {
422
+ await query(
423
+ "INSERT INTO purchase_orders " +
424
+ "(id, vendor_slug, status, expected_delivery, vendor_reference, notes, " +
425
+ " submitted_at, submitted_by, confirmed_at, closed_at, cancelled_at, " +
426
+ " cancel_reason, created_at, updated_at) " +
427
+ "VALUES (?1, ?2, 'draft', ?3, NULL, ?4, NULL, NULL, NULL, NULL, NULL, NULL, ?5, ?5)",
428
+ [id, vendorSlug, expected, notes, ts],
429
+ );
430
+ for (var i = 0; i < lines.length; i += 1) {
431
+ var l = lines[i];
432
+ await query(
433
+ "INSERT INTO purchase_order_lines " +
434
+ "(id, po_id, sku, quantity_ordered, quantity_received, unit_cost_minor, " +
435
+ " currency, created_at, updated_at) " +
436
+ "VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6, ?7, ?7)",
437
+ [_b().uuid.v7(), id, l.sku, l.quantity, l.unit_cost_minor, l.currency, ts],
438
+ );
439
+ }
440
+ } catch (e) {
441
+ // Best-effort rollback — D1 doesn't expose a transaction
442
+ // handle to this primitive, so the compensating DELETE drops
443
+ // the header + ON DELETE CASCADE clears any landed lines.
444
+ try { await query("DELETE FROM purchase_orders WHERE id = ?1", [id]); }
445
+ catch (_e2) { /* drop-silent — the original error is what the caller needs */ }
446
+ throw e;
447
+ }
448
+
449
+ return await _hydrated(id);
450
+ },
451
+
452
+ // FSM: draft -> submitted. Captures the operator identity (or
453
+ // automation tag) that pushed the PO out to the vendor. Refuses
454
+ // anything but draft.
455
+ submitToVendor: async function (input) {
456
+ if (!input || typeof input !== "object") {
457
+ throw new TypeError("purchase-orders.submitToVendor: input object required");
458
+ }
459
+ var poId = _id(input.po_id, "po_id");
460
+ var submittedBy = _shortText(input.submitted_by, "submitted_by", MAX_SUBMITTED_BY_LEN);
461
+ var current = await _getPORaw(poId);
462
+ if (!current) {
463
+ var miss = new Error("purchase-orders.submitToVendor: PO " + poId + " not found");
464
+ miss.code = "PO_NOT_FOUND";
465
+ throw miss;
466
+ }
467
+ if (current.status !== "draft") {
468
+ var refused = new Error("purchase-orders.submitToVendor: refused — PO is " + current.status +
469
+ ", only draft POs can be submitted");
470
+ refused.code = "PO_TRANSITION_REFUSED";
471
+ throw refused;
472
+ }
473
+ var ts = _now();
474
+ await query(
475
+ "UPDATE purchase_orders SET status = 'submitted', submitted_at = ?1, " +
476
+ "submitted_by = ?2, updated_at = ?1 WHERE id = ?3",
477
+ [ts, submittedBy || null, poId],
478
+ );
479
+ return await _hydrated(poId);
480
+ },
481
+
482
+ // FSM: submitted -> confirmed. Captures vendor's confirmation
483
+ // timestamp + reference number. The operator supplies confirmed_at
484
+ // explicitly — the vendor's confirmation arrives async (email /
485
+ // EDI / phone) and the operator stamps the wall-clock when they
486
+ // log the acknowledgement.
487
+ confirmByVendor: async function (input) {
488
+ if (!input || typeof input !== "object") {
489
+ throw new TypeError("purchase-orders.confirmByVendor: input object required");
490
+ }
491
+ var poId = _id(input.po_id, "po_id");
492
+ if (input.confirmed_at == null) {
493
+ throw new TypeError("purchase-orders.confirmByVendor: confirmed_at required (epoch ms)");
494
+ }
495
+ var confirmedAt = _ts(input.confirmed_at, "confirmed_at");
496
+ var vendorRef = _shortText(input.vendor_reference, "vendor_reference", MAX_VENDOR_REF_LEN);
497
+ var current = await _getPORaw(poId);
498
+ if (!current) {
499
+ var miss = new Error("purchase-orders.confirmByVendor: PO " + poId + " not found");
500
+ miss.code = "PO_NOT_FOUND";
501
+ throw miss;
502
+ }
503
+ if (current.status !== "submitted") {
504
+ var refused = new Error("purchase-orders.confirmByVendor: refused — PO is " + current.status +
505
+ ", only submitted POs can be confirmed");
506
+ refused.code = "PO_TRANSITION_REFUSED";
507
+ throw refused;
508
+ }
509
+ var ts = _now();
510
+ await query(
511
+ "UPDATE purchase_orders SET status = 'confirmed', confirmed_at = ?1, " +
512
+ "vendor_reference = ?2, updated_at = ?3 WHERE id = ?4",
513
+ [confirmedAt, vendorRef || null, ts, poId],
514
+ );
515
+ return await _hydrated(poId);
516
+ },
517
+
518
+ // Record a partial (or full) receipt against a confirmed /
519
+ // partially_received PO. Walks `received_lines`, advances each
520
+ // line's quantity_received, optionally updates the captured
521
+ // unit_cost_minor (vendor invoiced different to quoted), and
522
+ // recomputes the PO status: any-received -> partially_received,
523
+ // every-line-full -> received. Composes inventoryReceive.draft +
524
+ // .apply when the handle is wired so the catalog's inventory
525
+ // table gets the restock in lockstep.
526
+ //
527
+ // Refuses receipts against SKUs that weren't on the PO, refuses
528
+ // over-receipt (received + new > ordered), refuses transitions
529
+ // from non-confirmed/partial states (draft / submitted / closed /
530
+ // cancelled / received).
531
+ recordPartialReceipt: async function (input) {
532
+ if (!input || typeof input !== "object") {
533
+ throw new TypeError("purchase-orders.recordPartialReceipt: input object required");
534
+ }
535
+ var poId = _id(input.po_id, "po_id");
536
+ if (!Array.isArray(input.received_lines) || !input.received_lines.length) {
537
+ throw new TypeError("purchase-orders.recordPartialReceipt: received_lines must be a non-empty array");
538
+ }
539
+ var receivedBy = _shortText(input.received_by, "received_by", MAX_RECEIVED_BY_LEN);
540
+ var reference = input.reference != null
541
+ ? _reference(input.reference, "reference")
542
+ : null;
543
+
544
+ // Validate received_lines shape up front; build a sku -> entry
545
+ // map so the per-line UPDATE pass is O(lines).
546
+ var rxMap = Object.create(null);
547
+ for (var i = 0; i < input.received_lines.length; i += 1) {
548
+ var rl = input.received_lines[i];
549
+ if (!rl || typeof rl !== "object") {
550
+ throw new TypeError("purchase-orders.recordPartialReceipt: received_lines[" + i + "] must be an object");
551
+ }
552
+ var sku = _sku(rl.sku);
553
+ _quantity(rl.quantity_received, "received_lines[" + i + "].quantity_received");
554
+ if (rl.unit_cost_minor != null) {
555
+ _unitCost(rl.unit_cost_minor, "received_lines[" + i + "].unit_cost_minor");
556
+ }
557
+ if (Object.prototype.hasOwnProperty.call(rxMap, sku)) {
558
+ throw new TypeError("purchase-orders.recordPartialReceipt: duplicate sku " +
559
+ JSON.stringify(sku) + " in received_lines");
560
+ }
561
+ rxMap[sku] = {
562
+ quantity_received: rl.quantity_received,
563
+ unit_cost_minor: rl.unit_cost_minor == null ? null : rl.unit_cost_minor,
564
+ };
565
+ }
566
+
567
+ var current = await _getPORaw(poId);
568
+ if (!current) {
569
+ var miss = new Error("purchase-orders.recordPartialReceipt: PO " + poId + " not found");
570
+ miss.code = "PO_NOT_FOUND";
571
+ throw miss;
572
+ }
573
+ if (current.status !== "confirmed" && current.status !== "partially_received") {
574
+ var refused = new Error("purchase-orders.recordPartialReceipt: refused — PO is " +
575
+ current.status + ", only confirmed or partially_received POs can record receipts");
576
+ refused.code = "PO_TRANSITION_REFUSED";
577
+ throw refused;
578
+ }
579
+
580
+ var lines = await _getLinesRaw(poId);
581
+ var skuToLine = Object.create(null);
582
+ for (var s = 0; s < lines.length; s += 1) {
583
+ skuToLine[lines[s].sku] = lines[s];
584
+ }
585
+
586
+ // Pre-flight: every sku in received_lines must be on the PO,
587
+ // and the new running total can't exceed quantity_ordered. The
588
+ // pre-flight runs before any UPDATE so a single bad line
589
+ // doesn't half-apply the receipt.
590
+ var rxSkus = Object.keys(rxMap);
591
+ for (var t = 0; t < rxSkus.length; t += 1) {
592
+ var rsku = rxSkus[t];
593
+ var line = skuToLine[rsku];
594
+ if (!line) {
595
+ throw new TypeError("purchase-orders.recordPartialReceipt: sku " +
596
+ JSON.stringify(rsku) + " is not on PO " + poId);
597
+ }
598
+ var prior = Number(line.quantity_received || 0);
599
+ var addQ = rxMap[rsku].quantity_received;
600
+ var ord = Number(line.quantity_ordered);
601
+ if (prior + addQ > ord) {
602
+ throw new TypeError("purchase-orders.recordPartialReceipt: over-receipt for sku " +
603
+ JSON.stringify(rsku) + " — ordered=" + ord + ", already_received=" + prior +
604
+ ", attempting to add=" + addQ);
605
+ }
606
+ }
607
+
608
+ // Compose inventoryReceive.draft + apply BEFORE advancing the
609
+ // PO counters. If the catalog refuses (unknown SKU, decimal
610
+ // qty, etc.) the PO state stays consistent with the catalog —
611
+ // a half-applied receipt across two writeable surfaces is the
612
+ // worst-case for reconciliation. The receipt primitive's draft
613
+ // step requires a unique `reference`; when the caller didn't
614
+ // supply one, derive a deterministic-per-call value from the
615
+ // PO id + occurrence timestamp so two recordPartialReceipt
616
+ // calls on the same PO produce distinct receipt rows.
617
+ var ts = _now();
618
+ var receiptResult = null;
619
+ if (receiveHandle) {
620
+ var receiptRef = reference != null
621
+ ? reference
622
+ : ("po-" + poId + "-" + ts);
623
+ var receiptLines = [];
624
+ for (var u = 0; u < rxSkus.length; u += 1) {
625
+ var rxe = rxMap[rxSkus[u]];
626
+ var lineForCurrency = skuToLine[rxSkus[u]];
627
+ var unitCost = rxe.unit_cost_minor != null
628
+ ? rxe.unit_cost_minor
629
+ : Number(lineForCurrency.unit_cost_minor);
630
+ receiptLines.push({
631
+ sku: rxSkus[u],
632
+ qty_received: rxe.quantity_received,
633
+ unit_cost_minor: unitCost,
634
+ unit_cost_currency: lineForCurrency.currency,
635
+ });
636
+ }
637
+ var draft = await receiveHandle.draft({
638
+ reference: receiptRef,
639
+ supplier: current.vendor_slug,
640
+ received_by: receivedBy,
641
+ notes: "purchase-orders:" + poId,
642
+ total_currency: skuToLine[rxSkus[0]].currency,
643
+ received_at: ts,
644
+ lines: receiptLines,
645
+ });
646
+ var applied = await receiveHandle.apply(draft.id);
647
+ receiptResult = {
648
+ receipt_id: draft.id,
649
+ applied_count: applied.applied_count,
650
+ stock_changes: applied.stock_changes,
651
+ };
652
+ }
653
+
654
+ // Now advance the PO counters. The pre-flight cap above
655
+ // guarantees no over-receipt; the inventoryReceive composition
656
+ // (if wired) has already landed without throwing.
657
+ for (var v = 0; v < rxSkus.length; v += 1) {
658
+ var line2 = skuToLine[rxSkus[v]];
659
+ var rxe2 = rxMap[rxSkus[v]];
660
+ var newQty = Number(line2.quantity_received || 0) + rxe2.quantity_received;
661
+ if (rxe2.unit_cost_minor != null) {
662
+ await query(
663
+ "UPDATE purchase_order_lines SET quantity_received = ?1, " +
664
+ "unit_cost_minor = ?2, updated_at = ?3 WHERE id = ?4",
665
+ [newQty, rxe2.unit_cost_minor, ts, line2.id],
666
+ );
667
+ } else {
668
+ await query(
669
+ "UPDATE purchase_order_lines SET quantity_received = ?1, updated_at = ?2 WHERE id = ?3",
670
+ [newQty, ts, line2.id],
671
+ );
672
+ }
673
+ }
674
+
675
+ // Recompute the PO-level status from the now-current line
676
+ // state. The status transition only flips forward (no
677
+ // 'received' -> 'partially_received' regression).
678
+ var updatedLines = await _getLinesRaw(poId);
679
+ var nextStatus = _statusAfterReceipt(updatedLines, current.status);
680
+ if (nextStatus !== current.status) {
681
+ await query(
682
+ "UPDATE purchase_orders SET status = ?1, updated_at = ?2 WHERE id = ?3",
683
+ [nextStatus, ts, poId],
684
+ );
685
+ } else {
686
+ await query(
687
+ "UPDATE purchase_orders SET updated_at = ?1 WHERE id = ?2",
688
+ [ts, poId],
689
+ );
690
+ }
691
+
692
+ var hydrated = await _hydrated(poId);
693
+ hydrated.receipt = receiptResult;
694
+ return hydrated;
695
+ },
696
+
697
+ // FSM: received -> closed. The PO is fully reconciled — every
698
+ // ordered unit accounted for. Refuses if the PO isn't in the
699
+ // 'received' state (operator can't close a partial PO; they
700
+ // either await full receipt or move the open lines to a new PO
701
+ // and cancel this one upstream of confirmation).
702
+ closePO: async function (input) {
703
+ if (!input || typeof input !== "object") {
704
+ throw new TypeError("purchase-orders.closePO: input object required");
705
+ }
706
+ var poId = _id(input.po_id, "po_id");
707
+ var current = await _getPORaw(poId);
708
+ if (!current) {
709
+ var miss = new Error("purchase-orders.closePO: PO " + poId + " not found");
710
+ miss.code = "PO_NOT_FOUND";
711
+ throw miss;
712
+ }
713
+ if (current.status !== "received") {
714
+ var refused = new Error("purchase-orders.closePO: refused — PO is " + current.status +
715
+ ", only fully-received POs can be closed");
716
+ refused.code = "PO_TRANSITION_REFUSED";
717
+ throw refused;
718
+ }
719
+ // Defensive cross-check: every line received >= ordered. The
720
+ // recordPartialReceipt path guarantees status advances to
721
+ // 'received' only when every line is full, but verifying at
722
+ // close time costs one read and rules out a corrupted state
723
+ // path closing a PO with phantom open lines.
724
+ var lines = await _getLinesRaw(poId);
725
+ for (var i = 0; i < lines.length; i += 1) {
726
+ if (Number(lines[i].quantity_received) < Number(lines[i].quantity_ordered)) {
727
+ var badState = new Error("purchase-orders.closePO: refused — line for sku " +
728
+ JSON.stringify(lines[i].sku) + " is not fully received");
729
+ badState.code = "PO_LINE_NOT_FULL";
730
+ throw badState;
731
+ }
732
+ }
733
+ var ts = _now();
734
+ await query(
735
+ "UPDATE purchase_orders SET status = 'closed', closed_at = ?1, updated_at = ?1 WHERE id = ?2",
736
+ [ts, poId],
737
+ );
738
+ return await _hydrated(poId);
739
+ },
740
+
741
+ // FSM: draft|submitted -> cancelled. Once the vendor has
742
+ // confirmed, cancellation is a commercial conversation, not a
743
+ // database transition — the operator records the outcome via
744
+ // notes and either receives the goods anyway or closes the PO
745
+ // by a custom path the operator pipeline owns.
746
+ cancelPO: async function (input) {
747
+ if (!input || typeof input !== "object") {
748
+ throw new TypeError("purchase-orders.cancelPO: input object required");
749
+ }
750
+ var poId = _id(input.po_id, "po_id");
751
+ var reason = _shortText(input.reason, "reason", MAX_CANCEL_REASON_LEN);
752
+ if (!reason.length) {
753
+ throw new TypeError("purchase-orders.cancelPO: reason must be a non-empty string");
754
+ }
755
+ var current = await _getPORaw(poId);
756
+ if (!current) {
757
+ var miss = new Error("purchase-orders.cancelPO: PO " + poId + " not found");
758
+ miss.code = "PO_NOT_FOUND";
759
+ throw miss;
760
+ }
761
+ if (current.status !== "draft" && current.status !== "submitted") {
762
+ var refused = new Error("purchase-orders.cancelPO: refused — PO is " + current.status +
763
+ ", only draft or submitted POs can be cancelled through this surface");
764
+ refused.code = "PO_TRANSITION_REFUSED";
765
+ throw refused;
766
+ }
767
+ var ts = _now();
768
+ await query(
769
+ "UPDATE purchase_orders SET status = 'cancelled', cancelled_at = ?1, " +
770
+ "cancel_reason = ?2, updated_at = ?1 WHERE id = ?3",
771
+ [ts, reason, poId],
772
+ );
773
+ return await _hydrated(poId);
774
+ },
775
+
776
+ // Read a hydrated PO + its lines. Returns null on miss so the
777
+ // caller-handler maps cleanly to HTTP 404.
778
+ getPO: async function (poId) {
779
+ var id = _id(poId, "po_id");
780
+ return await _hydrated(id);
781
+ },
782
+
783
+ // List POs. Optional `status` + `vendor_slug` filters. Sorted by
784
+ // created_at DESC so the operator's freshest POs land at the top.
785
+ listPOs: async function (listOpts) {
786
+ listOpts = listOpts || {};
787
+ var hasStatus = listOpts.status !== undefined && listOpts.status !== null;
788
+ var hasVendor = listOpts.vendor_slug !== undefined && listOpts.vendor_slug !== null;
789
+ if (hasStatus) _status(listOpts.status);
790
+ if (hasVendor) _slug(listOpts.vendor_slug, "vendor_slug");
791
+ var sql, params;
792
+ if (hasStatus && hasVendor) {
793
+ sql = "SELECT * FROM purchase_orders WHERE status = ?1 AND vendor_slug = ?2 " +
794
+ "ORDER BY created_at DESC, id DESC";
795
+ params = [listOpts.status, listOpts.vendor_slug];
796
+ } else if (hasStatus) {
797
+ sql = "SELECT * FROM purchase_orders WHERE status = ?1 " +
798
+ "ORDER BY created_at DESC, id DESC";
799
+ params = [listOpts.status];
800
+ } else if (hasVendor) {
801
+ sql = "SELECT * FROM purchase_orders WHERE vendor_slug = ?1 " +
802
+ "ORDER BY created_at DESC, id DESC";
803
+ params = [listOpts.vendor_slug];
804
+ } else {
805
+ sql = "SELECT * FROM purchase_orders ORDER BY created_at DESC, id DESC";
806
+ params = [];
807
+ }
808
+ var r = await query(sql, params);
809
+ var out = [];
810
+ for (var i = 0; i < r.rows.length; i += 1) {
811
+ var hydrated = _hydratePO(r.rows[i]);
812
+ hydrated.lines = (await _getLinesRaw(r.rows[i].id)).map(_hydrateLine);
813
+ out.push(hydrated);
814
+ }
815
+ return out;
816
+ },
817
+
818
+ // Read lines only (without the PO header). Useful for receipt
819
+ // reconciliation UIs that already have the header loaded.
820
+ linesForPO: async function (poId) {
821
+ var id = _id(poId, "po_id");
822
+ var current = await _getPORaw(id);
823
+ if (!current) return null;
824
+ var rows = await _getLinesRaw(id);
825
+ return rows.map(_hydrateLine);
826
+ },
827
+
828
+ // Patch-style update. Only ALLOWED_UPDATE_COLUMNS may change, and
829
+ // only while the PO is in 'draft' state. Updating `lines`
830
+ // replaces the entire line set (operator-driven full revision);
831
+ // partial-line edits on a draft PO are an upstream concern the
832
+ // operator does by composing createDraft + delete-and-replace if
833
+ // the granularity matters more than the wholesale replace.
834
+ update: async function (poId, patch) {
835
+ var id = _id(poId, "po_id");
836
+ if (!patch || typeof patch !== "object") {
837
+ throw new TypeError("purchase-orders.update: patch object required");
838
+ }
839
+ var keys = Object.keys(patch);
840
+ if (!keys.length) {
841
+ throw new TypeError("purchase-orders.update: patch must contain at least one column");
842
+ }
843
+ for (var i = 0; i < keys.length; i += 1) {
844
+ if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
845
+ throw new TypeError("purchase-orders.update: column '" + keys[i] + "' not updatable");
846
+ }
847
+ }
848
+
849
+ var current = await _getPORaw(id);
850
+ if (!current) return null;
851
+ if (current.status !== "draft") {
852
+ var refused = new Error("purchase-orders.update: refused — PO is " + current.status +
853
+ ", only draft POs can be updated");
854
+ refused.code = "PO_TRANSITION_REFUSED";
855
+ throw refused;
856
+ }
857
+
858
+ var ts = _now();
859
+ var sets = [];
860
+ var params = [];
861
+ var idx = 1;
862
+ function _set(col, val) {
863
+ sets.push(col + " = ?" + idx);
864
+ params.push(val);
865
+ idx += 1;
866
+ }
867
+ if (Object.prototype.hasOwnProperty.call(patch, "notes")) {
868
+ _set("notes", _shortText(patch.notes, "notes", MAX_NOTES_LEN));
869
+ }
870
+ if (Object.prototype.hasOwnProperty.call(patch, "expected_delivery")) {
871
+ _set("expected_delivery", _ts(patch.expected_delivery, "expected_delivery"));
872
+ }
873
+
874
+ var hasLinesPatch = Object.prototype.hasOwnProperty.call(patch, "lines");
875
+ var nextLines = null;
876
+ if (hasLinesPatch) {
877
+ nextLines = _validateLines(patch.lines, "lines");
878
+ }
879
+
880
+ // Always bump updated_at — every update path advances the
881
+ // freshness signal, even when only a lines-replace happens
882
+ // (the per-row updated_at on lines covers lines; the header
883
+ // updated_at covers the PO).
884
+ if (sets.length) {
885
+ _set("updated_at", ts);
886
+ params.push(id);
887
+ var sql = "UPDATE purchase_orders SET " + sets.join(", ") + " WHERE id = ?" + idx;
888
+ await query(sql, params);
889
+ } else {
890
+ await query(
891
+ "UPDATE purchase_orders SET updated_at = ?1 WHERE id = ?2",
892
+ [ts, id],
893
+ );
894
+ }
895
+
896
+ if (hasLinesPatch) {
897
+ // Replace lines wholesale. ON DELETE CASCADE isn't useful
898
+ // here (we're not deleting the parent); a direct DELETE +
899
+ // re-INSERT keeps the operation explicit. The pre-flight
900
+ // validation in _validateLines guarantees the replacement
901
+ // shape is well-formed.
902
+ await query("DELETE FROM purchase_order_lines WHERE po_id = ?1", [id]);
903
+ for (var k = 0; k < nextLines.length; k += 1) {
904
+ var l = nextLines[k];
905
+ await query(
906
+ "INSERT INTO purchase_order_lines " +
907
+ "(id, po_id, sku, quantity_ordered, quantity_received, unit_cost_minor, " +
908
+ " currency, created_at, updated_at) " +
909
+ "VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6, ?7, ?7)",
910
+ [_b().uuid.v7(), id, l.sku, l.quantity, l.unit_cost_minor, l.currency, ts],
911
+ );
912
+ }
913
+ }
914
+
915
+ return await _hydrated(id);
916
+ },
917
+ };
918
+ }
919
+
920
+ module.exports = {
921
+ create: create,
922
+ PO_STATUSES: PO_STATUSES,
923
+ };