@blamejs/blamejs-shop 0.0.56 → 0.0.57

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,774 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.inventoryLocations
4
+ * @title Inventory locations — multi-warehouse stock + order routing
5
+ *
6
+ * @intro
7
+ * The catalog inventory surface holds a single `stock_on_hand`
8
+ * per SKU — enough until the operator runs from more than one
9
+ * physical bucket. Multi-location operators (a warehouse plus a
10
+ * retail counter, a regional warehouse plus a dropship supplier,
11
+ * a network of stores each picking from their own shelf) need
12
+ * per-location stock counts AND a routing layer that picks which
13
+ * location ships any given order.
14
+ *
15
+ * This primitive is the location-aware counterpart of the
16
+ * catalog's single-bucket inventory. It owns three tables:
17
+ *
18
+ * inventory_locations — operator-defined places (warehouse,
19
+ * retail, dropship). `code` is the
20
+ * operator-chosen short id (`WH-EAST`,
21
+ * `STORE-NYC`); priority controls
22
+ * routing order.
23
+ * inventory_stock — per (sku, location_code) quantity.
24
+ * The composite PK gives the upsert
25
+ * path a single INSERT ... ON CONFLICT.
26
+ * inventory_adjustments — append-only audit log: every
27
+ * setStock / adjustStock /
28
+ * transferStock writes a row.
29
+ *
30
+ * Verbs:
31
+ * defineLocation — register a location (refuses
32
+ * duplicate code, invalid type, etc).
33
+ * listLocations / getLocation / updateLocation / deactivateLocation
34
+ * — operator CRUD over the location set.
35
+ * setStock — absolute set (overwrite).
36
+ * adjustStock — relative delta with audit row.
37
+ * transferStock — atomic two-row write moving qty
38
+ * from one location to another; if
39
+ * the source doesn't have enough, the
40
+ * whole transfer refuses (no money
41
+ * created, no row half-committed).
42
+ * stockForSku — `{ total, by_location: [...] }`.
43
+ * totalForSku — sum across every location.
44
+ * availableLocations — locations with quantity > 0.
45
+ * routeOrder — given `{ lines: [{ sku, qty }] }`,
46
+ * returns `{ allocation, unfulfillable }`
47
+ * under one of three strategies:
48
+ * 'priority-fill' (default)
49
+ * 'single-location-cheapest'
50
+ * 'nearest-by-postal-prefix'
51
+ *
52
+ * Composition:
53
+ * - b.uuid.v7 — adjustment row PKs (sortable by insertion)
54
+ * - catalog — held for future cross-checks (current
55
+ * version doesn't read from it; the
56
+ * factory still requires it so the boot
57
+ * wiring fails loud when a caller forgets
58
+ * to provide one)
59
+ *
60
+ * Three-tier input validation (use the discipline; don't write
61
+ * the labels): every public verb on this module is a defensive
62
+ * request-shape reader OR a config-time entry point. Both throw
63
+ * on bad input. No drop-silent hot-path sinks here.
64
+ */
65
+
66
+ var bShop;
67
+ function _b() {
68
+ if (!bShop) bShop = require("./index");
69
+ return bShop.framework;
70
+ }
71
+
72
+ // ---- constants ----------------------------------------------------------
73
+
74
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
75
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
76
+ var NAME_RE = /^[\S\s]{1,128}$/;
77
+ var MAX_REASON = 256;
78
+ var MAX_LINES = 1000;
79
+ var TYPES = Object.freeze(["warehouse", "retail", "dropship"]);
80
+ var STRATEGIES = Object.freeze([
81
+ "priority-fill",
82
+ "single-location-cheapest",
83
+ "nearest-by-postal-prefix",
84
+ ]);
85
+
86
+ // ---- validators ---------------------------------------------------------
87
+
88
+ function _code(s, label) {
89
+ if (typeof s !== "string" || !CODE_RE.test(s)) {
90
+ throw new TypeError("inventory-locations: " + (label || "code") +
91
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
92
+ }
93
+ }
94
+ function _sku(s) {
95
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
96
+ throw new TypeError("inventory-locations: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
97
+ }
98
+ }
99
+ function _name(s) {
100
+ if (typeof s !== "string" || !NAME_RE.test(s) || s.length > 128) {
101
+ throw new TypeError("inventory-locations: name must be a non-empty string ≤ 128 chars");
102
+ }
103
+ }
104
+ function _type(s) {
105
+ if (typeof s !== "string" || TYPES.indexOf(s) === -1) {
106
+ throw new TypeError("inventory-locations: type must be one of " + TYPES.join(", ") +
107
+ ", got " + JSON.stringify(s));
108
+ }
109
+ }
110
+ function _priority(n) {
111
+ if (!Number.isInteger(n) || n < 0 || n > 1000000) {
112
+ throw new TypeError("inventory-locations: priority must be a non-negative integer ≤ 1000000");
113
+ }
114
+ }
115
+ function _nonNegInt(n, label) {
116
+ if (!Number.isInteger(n) || n < 0) {
117
+ throw new TypeError("inventory-locations: " + label + " must be a non-negative integer");
118
+ }
119
+ }
120
+ function _int(n, label) {
121
+ if (!Number.isInteger(n)) {
122
+ throw new TypeError("inventory-locations: " + label + " must be an integer");
123
+ }
124
+ }
125
+ function _positiveInt(n, label) {
126
+ if (!Number.isInteger(n) || n <= 0) {
127
+ throw new TypeError("inventory-locations: " + label + " must be a positive integer");
128
+ }
129
+ }
130
+ function _reason(s) {
131
+ if (s == null) return "";
132
+ if (typeof s !== "string" || s.length > MAX_REASON) {
133
+ throw new TypeError("inventory-locations: reason must be a string ≤ " + MAX_REASON + " chars");
134
+ }
135
+ return s;
136
+ }
137
+ function _addressJson(v) {
138
+ if (v == null) return null;
139
+ // Either pre-serialized JSON string or an object the primitive
140
+ // serializes for the operator. Refuse anything else — the
141
+ // address has to round-trip through JSON cleanly.
142
+ if (typeof v === "string") {
143
+ try { JSON.parse(v); }
144
+ catch (_e) { throw new TypeError("inventory-locations: address must be a JSON-parseable string or a plain object"); }
145
+ if (v.length > 4000) {
146
+ throw new TypeError("inventory-locations: address JSON must be ≤ 4000 chars");
147
+ }
148
+ return v;
149
+ }
150
+ if (typeof v === "object") {
151
+ var s;
152
+ try { s = JSON.stringify(v); }
153
+ catch (_e2) { throw new TypeError("inventory-locations: address object must serialize via JSON.stringify"); }
154
+ if (s.length > 4000) {
155
+ throw new TypeError("inventory-locations: address JSON must be ≤ 4000 chars");
156
+ }
157
+ return s;
158
+ }
159
+ throw new TypeError("inventory-locations: address must be a JSON-parseable string or a plain object");
160
+ }
161
+
162
+ function _now() { return Date.now(); }
163
+
164
+ function _parseAddress(raw) {
165
+ if (raw == null) return null;
166
+ try { return JSON.parse(raw); }
167
+ catch (_e) { return null; }
168
+ }
169
+
170
+ // ---- factory ------------------------------------------------------------
171
+
172
+ function create(opts) {
173
+ opts = opts || {};
174
+ // The catalog handle is held so a future revision can cross-
175
+ // reference SKUs against catalog.products.exists(sku) etc — the
176
+ // current version doesn't read from it, but the factory still
177
+ // requires it so the boot wiring fails loud when a caller
178
+ // forgets to thread the catalog through.
179
+ if (!opts.catalog || typeof opts.catalog !== "object") {
180
+ throw new TypeError("inventory-locations.create: opts.catalog is required");
181
+ }
182
+ var query = opts.query;
183
+ if (!query) {
184
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
185
+ }
186
+
187
+ // Hydrate a row, parsing the JSON address + coercing the
188
+ // INTEGER active flag to a real boolean. Returns null on miss
189
+ // so the caller can map cleanly to HTTP 404.
190
+ function _shapeLocation(row) {
191
+ if (!row) return null;
192
+ return {
193
+ code: row.code,
194
+ name: row.name,
195
+ type: row.type,
196
+ address: _parseAddress(row.address_json),
197
+ priority: row.priority,
198
+ active: row.active === 1 || row.active === true,
199
+ created_at: row.created_at,
200
+ updated_at: row.updated_at,
201
+ };
202
+ }
203
+
204
+ // Append an audit row. Called inline from every stock-mutating
205
+ // verb. Throws if the underlying insert fails — callers don't
206
+ // catch this; the mutation should never be considered "applied"
207
+ // without the audit trail.
208
+ async function _audit(sku, locationCode, delta, reason) {
209
+ await query(
210
+ "INSERT INTO inventory_adjustments (id, sku, location_code, delta, reason, occurred_at) " +
211
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
212
+ [_b().uuid.v7(), sku, locationCode, delta, reason || "", _now()],
213
+ );
214
+ }
215
+
216
+ async function _getLocationRow(code) {
217
+ var r = await query("SELECT * FROM inventory_locations WHERE code = ?1", [code]);
218
+ return r.rows[0] || null;
219
+ }
220
+
221
+ async function _getStockRow(sku, code) {
222
+ var r = await query(
223
+ "SELECT * FROM inventory_stock WHERE sku = ?1 AND location_code = ?2",
224
+ [sku, code],
225
+ );
226
+ return r.rows[0] || null;
227
+ }
228
+
229
+ // Pull the active-locations list sorted by (priority ASC, code
230
+ // ASC). The deterministic tie-break by code matters: two
231
+ // locations at the same priority should always pick in the
232
+ // same order across calls so routing is reproducible.
233
+ async function _activeLocationsSorted() {
234
+ var r = await query(
235
+ "SELECT * FROM inventory_locations WHERE active = 1 ORDER BY priority ASC, code ASC",
236
+ [],
237
+ );
238
+ return r.rows.map(_shapeLocation);
239
+ }
240
+
241
+ // ---- routing strategies ----------------------------------------------
242
+
243
+ // priority-fill: walks active locations in (priority, code)
244
+ // order. For each line, pulls from each location greedily until
245
+ // the line's qty is satisfied. Unfilled qty lands on
246
+ // `unfulfillable`. Lines that span multiple locations are split
247
+ // across them — the result groups allocations by location_code
248
+ // so downstream packers see "this box ships from WH-EAST,
249
+ // contains these lines."
250
+ async function _routePriorityFill(lines) {
251
+ var locations = await _activeLocationsSorted();
252
+ var byLoc = {}; // code -> { location_code, lines: [{sku, quantity}] }
253
+ var unfulfillable = []; // [{ sku, quantity }]
254
+ for (var i = 0; i < lines.length; i += 1) {
255
+ var line = lines[i];
256
+ var remaining = line.quantity;
257
+ for (var j = 0; j < locations.length && remaining > 0; j += 1) {
258
+ var loc = locations[j];
259
+ var stock = await _getStockRow(line.sku, loc.code);
260
+ if (!stock || stock.quantity <= 0) continue;
261
+ var pick = Math.min(stock.quantity, remaining);
262
+ if (!byLoc[loc.code]) byLoc[loc.code] = { location_code: loc.code, lines: [] };
263
+ byLoc[loc.code].lines.push({ sku: line.sku, quantity: pick });
264
+ remaining -= pick;
265
+ }
266
+ if (remaining > 0) unfulfillable.push({ sku: line.sku, quantity: remaining });
267
+ }
268
+ // Emit allocations in priority order, not insertion order, so
269
+ // the caller sees the highest-priority shipper first.
270
+ var allocation = [];
271
+ for (var k = 0; k < locations.length; k += 1) {
272
+ var c = locations[k].code;
273
+ if (byLoc[c]) allocation.push(byLoc[c]);
274
+ }
275
+ return { allocation: allocation, unfulfillable: unfulfillable };
276
+ }
277
+
278
+ // single-location-cheapest: pick the single active location with
279
+ // the lowest priority that can fill EVERY line completely. If no
280
+ // single location can cover the whole order, every line lands
281
+ // on unfulfillable (the strategy by name refuses splits — the
282
+ // caller picks `priority-fill` if they want partial allocation).
283
+ async function _routeSingleLocationCheapest(lines) {
284
+ var locations = await _activeLocationsSorted();
285
+ for (var j = 0; j < locations.length; j += 1) {
286
+ var loc = locations[j];
287
+ var ok = true;
288
+ for (var i = 0; i < lines.length; i += 1) {
289
+ var stock = await _getStockRow(lines[i].sku, loc.code);
290
+ if (!stock || stock.quantity < lines[i].quantity) { ok = false; break; }
291
+ }
292
+ if (ok) {
293
+ var allocLines = lines.map(function (l) { return { sku: l.sku, quantity: l.quantity }; });
294
+ return {
295
+ allocation: [{ location_code: loc.code, lines: allocLines }],
296
+ unfulfillable: [],
297
+ };
298
+ }
299
+ }
300
+ // No single location can cover — push every line onto
301
+ // unfulfillable so the caller can either fall back to
302
+ // priority-fill or surface the gap to the operator.
303
+ return {
304
+ allocation: [],
305
+ unfulfillable: lines.map(function (l) { return { sku: l.sku, quantity: l.quantity }; }),
306
+ };
307
+ }
308
+
309
+ // nearest-by-postal-prefix: operator supplies `{ destinationPostal,
310
+ // postalMap }` where postalMap is `{ <location_code>: <postal-prefix-string> }`.
311
+ // The strategy scores each active location by the length of the
312
+ // shared prefix between destinationPostal and that location's
313
+ // entry; highest score wins, ties broken by priority then code.
314
+ // Once the winning order is decided, the strategy falls back to
315
+ // priority-fill semantics (greedy split across the ordered
316
+ // locations) so partial coverage doesn't drop the order.
317
+ async function _routeNearestByPostalPrefix(lines, strategyOpts) {
318
+ if (!strategyOpts || typeof strategyOpts !== "object") {
319
+ throw new TypeError("inventory-locations.routeOrder(nearest-by-postal-prefix): " +
320
+ "strategyOpts.destinationPostal + strategyOpts.postalMap required");
321
+ }
322
+ var dest = strategyOpts.destinationPostal;
323
+ if (typeof dest !== "string" || !dest.length || dest.length > 32) {
324
+ throw new TypeError("inventory-locations.routeOrder(nearest-by-postal-prefix): " +
325
+ "destinationPostal must be a non-empty string ≤ 32 chars");
326
+ }
327
+ var map = strategyOpts.postalMap;
328
+ if (!map || typeof map !== "object") {
329
+ throw new TypeError("inventory-locations.routeOrder(nearest-by-postal-prefix): " +
330
+ "postalMap must be an object of { location_code: postal-prefix }");
331
+ }
332
+ var locations = await _activeLocationsSorted();
333
+ function _sharedPrefix(a, b) {
334
+ if (typeof a !== "string" || typeof b !== "string") return 0;
335
+ var n = Math.min(a.length, b.length);
336
+ for (var i = 0; i < n; i += 1) {
337
+ if (a.charCodeAt(i) !== b.charCodeAt(i)) return i;
338
+ }
339
+ return n;
340
+ }
341
+ // Decorate locations with their score; sort by (score DESC,
342
+ // priority ASC, code ASC).
343
+ var scored = locations.map(function (loc) {
344
+ var prefix = map[loc.code];
345
+ var score = _sharedPrefix(dest, prefix || "");
346
+ return { loc: loc, score: score };
347
+ });
348
+ scored.sort(function (a, b) {
349
+ if (b.score !== a.score) return b.score - a.score;
350
+ if (a.loc.priority !== b.loc.priority) return a.loc.priority - b.loc.priority;
351
+ if (a.loc.code < b.loc.code) return -1;
352
+ if (a.loc.code > b.loc.code) return 1;
353
+ return 0;
354
+ });
355
+ var ordered = scored.map(function (s) { return s.loc; });
356
+ var byLoc = {};
357
+ var unfulfillable = [];
358
+ for (var i2 = 0; i2 < lines.length; i2 += 1) {
359
+ var line = lines[i2];
360
+ var remaining = line.quantity;
361
+ for (var j2 = 0; j2 < ordered.length && remaining > 0; j2 += 1) {
362
+ var loc2 = ordered[j2];
363
+ var stock = await _getStockRow(line.sku, loc2.code);
364
+ if (!stock || stock.quantity <= 0) continue;
365
+ var pick = Math.min(stock.quantity, remaining);
366
+ if (!byLoc[loc2.code]) byLoc[loc2.code] = { location_code: loc2.code, lines: [] };
367
+ byLoc[loc2.code].lines.push({ sku: line.sku, quantity: pick });
368
+ remaining -= pick;
369
+ }
370
+ if (remaining > 0) unfulfillable.push({ sku: line.sku, quantity: remaining });
371
+ }
372
+ var allocation = [];
373
+ for (var k2 = 0; k2 < ordered.length; k2 += 1) {
374
+ var c2 = ordered[k2].code;
375
+ if (byLoc[c2]) allocation.push(byLoc[c2]);
376
+ }
377
+ return { allocation: allocation, unfulfillable: unfulfillable };
378
+ }
379
+
380
+ return {
381
+
382
+ // Register a location. Refuses a duplicate code up front with
383
+ // a descriptive message instead of relying on the PK
384
+ // constraint's raw SQLITE_CONSTRAINT throw. The address is
385
+ // serialized to JSON for storage; readers parse it back via
386
+ // the row-shaper.
387
+ defineLocation: async function (input) {
388
+ if (!input || typeof input !== "object") {
389
+ throw new TypeError("inventory-locations.defineLocation: input object required");
390
+ }
391
+ _code(input.code, "code");
392
+ _name(input.name);
393
+ _type(input.type);
394
+ var priority = input.priority == null ? 100 : input.priority;
395
+ _priority(priority);
396
+ var active = input.active == null ? true : input.active;
397
+ if (typeof active !== "boolean") {
398
+ throw new TypeError("inventory-locations.defineLocation: active must be a boolean");
399
+ }
400
+ var address = _addressJson(input.address);
401
+
402
+ var dup = await query(
403
+ "SELECT code FROM inventory_locations WHERE code = ?1 LIMIT 1",
404
+ [input.code],
405
+ );
406
+ if (dup.rows.length) {
407
+ throw new TypeError("inventory-locations.defineLocation: code " +
408
+ JSON.stringify(input.code) + " already exists");
409
+ }
410
+
411
+ var ts = _now();
412
+ await query(
413
+ "INSERT INTO inventory_locations (code, name, type, address_json, priority, active, created_at, updated_at) " +
414
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
415
+ [input.code, input.name, input.type, address, priority, active ? 1 : 0, ts],
416
+ );
417
+ return _shapeLocation(await _getLocationRow(input.code));
418
+ },
419
+
420
+ // List locations. `active_only: true` filters out deactivated
421
+ // rows. Sort is (priority ASC, code ASC) — operators expect
422
+ // the routing order in the admin UI.
423
+ listLocations: async function (listOpts) {
424
+ listOpts = listOpts || {};
425
+ var sql, params;
426
+ if (listOpts.active_only) {
427
+ sql = "SELECT * FROM inventory_locations WHERE active = 1 ORDER BY priority ASC, code ASC";
428
+ params = [];
429
+ } else {
430
+ sql = "SELECT * FROM inventory_locations ORDER BY priority ASC, code ASC";
431
+ params = [];
432
+ }
433
+ var rows = (await query(sql, params)).rows;
434
+ return rows.map(_shapeLocation);
435
+ },
436
+
437
+ // Read one location. Returns null on miss so the caller-
438
+ // handler maps cleanly to HTTP 404.
439
+ getLocation: async function (code) {
440
+ _code(code, "code");
441
+ return _shapeLocation(await _getLocationRow(code));
442
+ },
443
+
444
+ // Patch a subset of mutable fields. `code` and `created_at`
445
+ // are immutable; every other field is patchable. Refuses an
446
+ // unknown code with a descriptive error.
447
+ updateLocation: async function (code, patch) {
448
+ _code(code, "code");
449
+ if (!patch || typeof patch !== "object") {
450
+ throw new TypeError("inventory-locations.updateLocation: patch object required");
451
+ }
452
+ var existing = await _getLocationRow(code);
453
+ if (!existing) {
454
+ throw new TypeError("inventory-locations.updateLocation: code " +
455
+ JSON.stringify(code) + " not found");
456
+ }
457
+ var sets = [];
458
+ var params = [];
459
+ var idx = 1;
460
+ if (Object.prototype.hasOwnProperty.call(patch, "name")) {
461
+ _name(patch.name);
462
+ sets.push("name = ?" + idx); params.push(patch.name); idx += 1;
463
+ }
464
+ if (Object.prototype.hasOwnProperty.call(patch, "type")) {
465
+ _type(patch.type);
466
+ sets.push("type = ?" + idx); params.push(patch.type); idx += 1;
467
+ }
468
+ if (Object.prototype.hasOwnProperty.call(patch, "priority")) {
469
+ _priority(patch.priority);
470
+ sets.push("priority = ?" + idx); params.push(patch.priority); idx += 1;
471
+ }
472
+ if (Object.prototype.hasOwnProperty.call(patch, "address")) {
473
+ var addr = _addressJson(patch.address);
474
+ sets.push("address_json = ?" + idx); params.push(addr); idx += 1;
475
+ }
476
+ if (Object.prototype.hasOwnProperty.call(patch, "active")) {
477
+ if (typeof patch.active !== "boolean") {
478
+ throw new TypeError("inventory-locations.updateLocation: active must be a boolean");
479
+ }
480
+ sets.push("active = ?" + idx); params.push(patch.active ? 1 : 0); idx += 1;
481
+ }
482
+ if (sets.length === 0) {
483
+ // No-op patch — still return the current row so the
484
+ // admin UI can refresh.
485
+ return _shapeLocation(existing);
486
+ }
487
+ sets.push("updated_at = ?" + idx); params.push(_now()); idx += 1;
488
+ params.push(code);
489
+ await query(
490
+ "UPDATE inventory_locations SET " + sets.join(", ") + " WHERE code = ?" + idx,
491
+ params,
492
+ );
493
+ return _shapeLocation(await _getLocationRow(code));
494
+ },
495
+
496
+ // Sugar over `updateLocation({ active: false })`. The row
497
+ // stays in the table so historical adjustments still resolve
498
+ // their location_code reference.
499
+ deactivateLocation: async function (code) {
500
+ _code(code, "code");
501
+ var existing = await _getLocationRow(code);
502
+ if (!existing) {
503
+ throw new TypeError("inventory-locations.deactivateLocation: code " +
504
+ JSON.stringify(code) + " not found");
505
+ }
506
+ await query(
507
+ "UPDATE inventory_locations SET active = 0, updated_at = ?1 WHERE code = ?2",
508
+ [_now(), code],
509
+ );
510
+ return _shapeLocation(await _getLocationRow(code));
511
+ },
512
+
513
+ // Absolute set: overwrites the (sku, location_code) row to
514
+ // exactly `quantity`. Audit row captures the signed delta vs
515
+ // the prior value so the audit log reflects the actual change.
516
+ setStock: async function (input) {
517
+ if (!input || typeof input !== "object") {
518
+ throw new TypeError("inventory-locations.setStock: input object required");
519
+ }
520
+ _sku(input.sku);
521
+ _code(input.location_code, "location_code");
522
+ _nonNegInt(input.quantity, "quantity");
523
+ var loc = await _getLocationRow(input.location_code);
524
+ if (!loc) {
525
+ throw new TypeError("inventory-locations.setStock: location_code " +
526
+ JSON.stringify(input.location_code) + " not found");
527
+ }
528
+ var prev = await _getStockRow(input.sku, input.location_code);
529
+ var prevQty = prev ? prev.quantity : 0;
530
+ var ts = _now();
531
+ if (prev) {
532
+ await query(
533
+ "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
534
+ [input.quantity, ts, input.sku, input.location_code],
535
+ );
536
+ } else {
537
+ await query(
538
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
539
+ [input.sku, input.location_code, input.quantity, ts],
540
+ );
541
+ }
542
+ var delta = input.quantity - prevQty;
543
+ await _audit(input.sku, input.location_code, delta, _reason(input.reason));
544
+ return { sku: input.sku, location_code: input.location_code, quantity: input.quantity, delta: delta };
545
+ },
546
+
547
+ // Relative delta with audit row. The composite quantity must
548
+ // remain non-negative — a negative delta that would drive the
549
+ // row below zero refuses the whole operation. Inserts a row
550
+ // at quantity 0 + delta if the (sku, location_code) pair has
551
+ // never been touched before (and delta is positive).
552
+ adjustStock: async function (input) {
553
+ if (!input || typeof input !== "object") {
554
+ throw new TypeError("inventory-locations.adjustStock: input object required");
555
+ }
556
+ _sku(input.sku);
557
+ _code(input.location_code, "location_code");
558
+ _int(input.delta, "delta");
559
+ if (input.delta === 0) {
560
+ throw new TypeError("inventory-locations.adjustStock: delta must be non-zero");
561
+ }
562
+ var loc = await _getLocationRow(input.location_code);
563
+ if (!loc) {
564
+ throw new TypeError("inventory-locations.adjustStock: location_code " +
565
+ JSON.stringify(input.location_code) + " not found");
566
+ }
567
+ var prev = await _getStockRow(input.sku, input.location_code);
568
+ var prevQty = prev ? prev.quantity : 0;
569
+ var next = prevQty + input.delta;
570
+ if (next < 0) {
571
+ throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
572
+ " would drive stock below zero (current=" + prevQty + ", sku=" + input.sku +
573
+ ", location=" + input.location_code + ")");
574
+ }
575
+ var ts = _now();
576
+ if (prev) {
577
+ await query(
578
+ "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
579
+ [next, ts, input.sku, input.location_code],
580
+ );
581
+ } else {
582
+ await query(
583
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
584
+ [input.sku, input.location_code, next, ts],
585
+ );
586
+ }
587
+ await _audit(input.sku, input.location_code, input.delta, _reason(input.reason));
588
+ return { sku: input.sku, location_code: input.location_code, quantity: next, delta: input.delta };
589
+ },
590
+
591
+ // Atomic two-row move. Reads the source quantity; if
592
+ // insufficient, refuses the whole transfer before mutating
593
+ // anything (no money created, no row half-committed). Writes
594
+ // two audit rows (negative on source, positive on destination)
595
+ // so the audit log preserves the pairing.
596
+ transferStock: async function (input) {
597
+ if (!input || typeof input !== "object") {
598
+ throw new TypeError("inventory-locations.transferStock: input object required");
599
+ }
600
+ _sku(input.sku);
601
+ _code(input.from_location, "from_location");
602
+ _code(input.to_location, "to_location");
603
+ if (input.from_location === input.to_location) {
604
+ throw new TypeError("inventory-locations.transferStock: from_location and to_location must differ");
605
+ }
606
+ _positiveInt(input.quantity, "quantity");
607
+
608
+ var fromLoc = await _getLocationRow(input.from_location);
609
+ if (!fromLoc) {
610
+ throw new TypeError("inventory-locations.transferStock: from_location " +
611
+ JSON.stringify(input.from_location) + " not found");
612
+ }
613
+ var toLoc = await _getLocationRow(input.to_location);
614
+ if (!toLoc) {
615
+ throw new TypeError("inventory-locations.transferStock: to_location " +
616
+ JSON.stringify(input.to_location) + " not found");
617
+ }
618
+
619
+ var fromRow = await _getStockRow(input.sku, input.from_location);
620
+ var fromQty = fromRow ? fromRow.quantity : 0;
621
+ if (fromQty < input.quantity) {
622
+ throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
623
+ input.from_location + " (have " + fromQty + ", need " + input.quantity + ")");
624
+ }
625
+ var toRow = await _getStockRow(input.sku, input.to_location);
626
+ var toQty = toRow ? toRow.quantity : 0;
627
+
628
+ var ts = _now();
629
+ // Decrement source first. If the destination upsert below
630
+ // fails for any reason, the rollback path re-increments the
631
+ // source so the invariant (total quantity unchanged) holds.
632
+ await query(
633
+ "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
634
+ [fromQty - input.quantity, ts, input.sku, input.from_location],
635
+ );
636
+ try {
637
+ if (toRow) {
638
+ await query(
639
+ "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
640
+ [toQty + input.quantity, ts, input.sku, input.to_location],
641
+ );
642
+ } else {
643
+ await query(
644
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
645
+ [input.sku, input.to_location, input.quantity, ts],
646
+ );
647
+ }
648
+ } catch (e) {
649
+ // Best-effort compensating restore on the source so the
650
+ // pair stays balanced. If THIS write also fails the
651
+ // operator will see two adjacent audit rows with no
652
+ // counterpart — the caller can replay or reconcile.
653
+ try {
654
+ await query(
655
+ "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
656
+ [fromQty, _now(), input.sku, input.from_location],
657
+ );
658
+ } catch (_e2) { /* drop-silent — the original error is what the caller needs to fix */ }
659
+ throw e;
660
+ }
661
+
662
+ var reason = _reason(input.reason);
663
+ await _audit(input.sku, input.from_location, -input.quantity, reason);
664
+ await _audit(input.sku, input.to_location, input.quantity, reason);
665
+
666
+ return {
667
+ sku: input.sku,
668
+ from_location: input.from_location,
669
+ to_location: input.to_location,
670
+ quantity: input.quantity,
671
+ from_after: fromQty - input.quantity,
672
+ to_after: toQty + input.quantity,
673
+ };
674
+ },
675
+
676
+ // Stock breakdown for a single SKU. by_location is ordered by
677
+ // (priority ASC, code ASC) — operators read it as the routing
678
+ // order. Locations with zero stock that have ever held the
679
+ // SKU are still included so the UI can show "out of stock at
680
+ // STORE-NYC" instead of silently dropping the row.
681
+ stockForSku: async function (sku) {
682
+ _sku(sku);
683
+ var r = await query(
684
+ "SELECT s.sku, s.location_code, s.quantity, l.priority " +
685
+ "FROM inventory_stock s " +
686
+ "JOIN inventory_locations l ON l.code = s.location_code " +
687
+ "WHERE s.sku = ?1 " +
688
+ "ORDER BY l.priority ASC, l.code ASC",
689
+ [sku],
690
+ );
691
+ var total = 0;
692
+ var byLocation = r.rows.map(function (row) {
693
+ total += row.quantity;
694
+ return { code: row.location_code, quantity: row.quantity };
695
+ });
696
+ return { total: total, by_location: byLocation };
697
+ },
698
+
699
+ // Sum across every location. Faster than stockForSku when the
700
+ // caller only needs the headline number.
701
+ totalForSku: async function (sku) {
702
+ _sku(sku);
703
+ var r = await query(
704
+ "SELECT COALESCE(SUM(quantity), 0) AS total FROM inventory_stock WHERE sku = ?1",
705
+ [sku],
706
+ );
707
+ return r.rows[0] ? Number(r.rows[0].total) : 0;
708
+ },
709
+
710
+ // Active locations with quantity > 0 for the SKU, ordered by
711
+ // (priority, code). Convenience for "where can we ship this
712
+ // SKU from?" admin views.
713
+ availableLocations: async function (sku) {
714
+ _sku(sku);
715
+ var r = await query(
716
+ "SELECT s.location_code, s.quantity, l.priority " +
717
+ "FROM inventory_stock s " +
718
+ "JOIN inventory_locations l ON l.code = s.location_code " +
719
+ "WHERE s.sku = ?1 AND s.quantity > 0 AND l.active = 1 " +
720
+ "ORDER BY l.priority ASC, l.code ASC",
721
+ [sku],
722
+ );
723
+ return r.rows.map(function (row) {
724
+ return { code: row.location_code, quantity: row.quantity };
725
+ });
726
+ },
727
+
728
+ // Route an order. Strategy is one of:
729
+ // 'priority-fill' (default — greedy split by priority)
730
+ // 'single-location-cheapest' (refuses splits)
731
+ // 'nearest-by-postal-prefix' (operator supplies postal map)
732
+ // Validates lines + dispatches.
733
+ routeOrder: async function (input) {
734
+ if (!input || typeof input !== "object") {
735
+ throw new TypeError("inventory-locations.routeOrder: input object required");
736
+ }
737
+ if (!Array.isArray(input.lines) || input.lines.length === 0) {
738
+ throw new TypeError("inventory-locations.routeOrder: lines must be a non-empty array");
739
+ }
740
+ if (input.lines.length > MAX_LINES) {
741
+ throw new TypeError("inventory-locations.routeOrder: lines must contain ≤ " + MAX_LINES + " entries");
742
+ }
743
+ var normalized = [];
744
+ for (var i = 0; i < input.lines.length; i += 1) {
745
+ var l = input.lines[i];
746
+ if (!l || typeof l !== "object") {
747
+ throw new TypeError("inventory-locations.routeOrder: lines[" + i + "] must be an object");
748
+ }
749
+ _sku(l.sku);
750
+ _positiveInt(l.quantity, "lines[" + i + "].quantity");
751
+ normalized.push({ sku: l.sku, quantity: l.quantity });
752
+ }
753
+ var strategy = input.strategy == null ? "priority-fill" : input.strategy;
754
+ if (STRATEGIES.indexOf(strategy) === -1) {
755
+ throw new TypeError("inventory-locations.routeOrder: strategy must be one of " +
756
+ STRATEGIES.join(", ") + ", got " + JSON.stringify(strategy));
757
+ }
758
+ if (strategy === "priority-fill") {
759
+ return await _routePriorityFill(normalized);
760
+ }
761
+ if (strategy === "single-location-cheapest") {
762
+ return await _routeSingleLocationCheapest(normalized);
763
+ }
764
+ // nearest-by-postal-prefix
765
+ return await _routeNearestByPostalPrefix(normalized, input.strategyOpts);
766
+ },
767
+ };
768
+ }
769
+
770
+ module.exports = {
771
+ create: create,
772
+ TYPES: TYPES,
773
+ STRATEGIES: STRATEGIES,
774
+ };