@blamejs/blamejs-shop 0.0.64 → 0.0.65
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 +2 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +20 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|