@blamejs/blamejs-shop 0.4.13 → 0.4.15

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/lib/admin.js CHANGED
@@ -37,6 +37,7 @@ var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
37
37
  var loyaltyRedemptionModule = require("./loyalty-redemption");
38
38
  var trustBadgesModule = require("./trust-badges");
39
39
  var cartModule = require("./cart"); // ABANDONED_* window/limit constants for the /admin/carts console
40
+ var inventoryWriteoffsModule = require("./inventory-writeoffs"); // WRITEOFF_REASONS enum for the /admin/inventory/writeoffs console
40
41
  var textGuard = require("./text-guard");
41
42
  var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
42
43
 
@@ -574,6 +575,10 @@ function mount(router, deps) {
574
575
  var searchSuggestions = deps.searchSuggestions || null; // featured-suggestion curation + popular-searches view disabled when absent
575
576
  var trustBadges = deps.trustBadges || null; // trust-badge authoring console disabled when absent
576
577
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
578
+ var inventoryLocations = deps.inventoryLocations || null; // stock-location CRUD + per-location levels console disabled when absent
579
+ var inventoryReceive = deps.inventoryReceive || null; // inbound-stock receive console disabled when absent
580
+ var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
581
+ var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
577
582
  // Read-only activity log at /admin/audit. Defaults ON — the framework
578
583
  // audit chain is always booted by createApp, so the screen always has a
579
584
  // data source (unlike the optional primitives above, which default off).
@@ -593,7 +598,7 @@ function mount(router, deps) {
593
598
  // `reports` is always present in the nav (read-only sales summary needs no
594
599
  // extra dep); its route mounts unconditionally and renders an unconfigured
595
600
  // notice when the salesReports primitive isn't wired.
596
- var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart };
601
+ var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs };
597
602
 
598
603
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
599
604
 
@@ -5273,6 +5278,406 @@ function mount(router, deps) {
5273
5278
  });
5274
5279
  }
5275
5280
 
5281
+ // ---- inventory-ops back-office --------------------------------------
5282
+ //
5283
+ // Per-location stock on top of the single-bucket catalog inventory. The
5284
+ // catalog `inventory.stock_on_hand` stays the storefront source of truth;
5285
+ // these screens keep it in step with the per-location detail so a
5286
+ // multi-location operator's warehouse breakdown never diverges from the
5287
+ // count the storefront sells against. A store that never defines a
5288
+ // location keeps using /admin/inventory unchanged — the default location
5289
+ // is implicit, zero config.
5290
+
5291
+ // Credit ALL THREE ledgers for an inbound receive of one (sku,
5292
+ // location) line:
5293
+ // 1. inventoryReceive.draft + apply — the batched-receipt record the
5294
+ // "Recent receipts" history reads, AND the storefront-aggregate
5295
+ // credit (apply composes catalog.inventory.restock, which fires the
5296
+ // low-stock observer). A unique reference is auto-generated so two
5297
+ // receives in the same millisecond don't collide on the UNIQUE
5298
+ // constraint.
5299
+ // 2. inventoryLocations.adjustStock(+qty) — the per-location detail +
5300
+ // its reason-coded inventory_adjustments audit row.
5301
+ //
5302
+ // The location credit lands first: it's the verb that refuses an unknown
5303
+ // / deactivated location (a credit never refuses on insufficiency), so a
5304
+ // bad location is rejected before any receipt row is written. If the
5305
+ // receipt record then fails (duplicate reference race, DB error) the
5306
+ // location credit is reversed so the per-location detail doesn't sit
5307
+ // ahead of the aggregate. Returns the location adjust result.
5308
+ async function _receiveToLocation(sku, locationCode, qty, reason) {
5309
+ var adj = await inventoryLocations.adjustStock({
5310
+ sku: sku, location_code: locationCode, delta: qty,
5311
+ reason: reason ? ("receive: " + reason) : "receive",
5312
+ });
5313
+ try {
5314
+ var ref = "RCV-" + Date.now().toString(36).toUpperCase() + "-" +
5315
+ b.uuid.v7().slice(0, 8);
5316
+ var draft = await inventoryReceive.draft({
5317
+ reference: ref,
5318
+ supplier: reason ? reason.slice(0, 256) : "",
5319
+ received_by: "admin-console",
5320
+ notes: locationCode ? ("location: " + locationCode) : "",
5321
+ lines: [{ sku: sku, qty_received: qty }],
5322
+ });
5323
+ // apply credits the storefront aggregate (restock → low-stock
5324
+ // observer). restock is a no-op for an un-tracked SKU (no inventory
5325
+ // row) — fine: an un-tracked SKU is unlimited in the storefront, so
5326
+ // the per-location detail is the only ledger that needed the credit.
5327
+ await inventoryReceive.apply(draft.id);
5328
+ } catch (e) {
5329
+ // Compensate the per-location credit so the detail doesn't disagree
5330
+ // with the aggregate, then surface the original failure.
5331
+ try {
5332
+ await inventoryLocations.adjustStock({
5333
+ sku: sku, location_code: locationCode, delta: -qty,
5334
+ reason: "receive: rollback",
5335
+ });
5336
+ } catch (_e2) { /* drop-silent — the original error is the operator's signal */ }
5337
+ throw e;
5338
+ }
5339
+ return adj;
5340
+ }
5341
+
5342
+ // Debit BOTH ledgers for a write-off: the per-location detail (via the
5343
+ // writeoffs primitive, which composes inventoryLocations.adjustStock and
5344
+ // owns the reason-coded audit row) AND the storefront aggregate (catalog
5345
+ // adjustOnHand, which fires the low-stock observer + refuses to eat into
5346
+ // held stock).
5347
+ //
5348
+ // The aggregate mirror is applied BEFORE the write-off is committed as a
5349
+ // real record so the two ledgers never silently diverge. adjustOnHand
5350
+ // returns `{ adjusted: false }` (not a throw) when the debit would eat
5351
+ // into stock already held for paid-but-unfulfilled orders — that's a real
5352
+ // operational conflict (you're writing off units promised to a paid
5353
+ // order), so we reverse the per-location debit and surface it as a
5354
+ // refusal rather than committing a write-off that leaves the storefront
5355
+ // count ahead of the physical shelf. A null result means the SKU is
5356
+ // un-tracked in the catalog aggregate (no inventory row) — the
5357
+ // per-location detail is then the only ledger, and the write-off stands.
5358
+ async function _writeoffFromLocation(input) {
5359
+ var row = await inventoryWriteoffs.recordWriteoff(input);
5360
+ if (input.location_code) {
5361
+ // The per-location debit already landed inside recordWriteoff. Mirror
5362
+ // it onto the storefront aggregate.
5363
+ var mirror = await catalog.inventory.adjustOnHand(input.sku, -input.quantity);
5364
+ if (mirror && mirror.adjusted === false) {
5365
+ // The aggregate refused — reverse the per-location debit AND the
5366
+ // write-off record so neither ledger carries a half-applied move,
5367
+ // then surface the conflict.
5368
+ try {
5369
+ await inventoryWriteoffs.reverseWriteoff({
5370
+ id: row.id,
5371
+ reason: "aggregate-conflict: would oversell held stock",
5372
+ });
5373
+ } catch (_e) { /* drop-silent — the thrown conflict is the operator's signal */ }
5374
+ throw new TypeError("admin.inventory.writeoff: " + input.quantity +
5375
+ " unit(s) of " + input.sku + " can't be written off — that would eat " +
5376
+ "into stock already held for paid orders. Fulfil or cancel those orders first.");
5377
+ }
5378
+ }
5379
+ return row;
5380
+ }
5381
+
5382
+ if (inventoryLocations) {
5383
+ // GET /admin/inventory/locations — location list with per-location
5384
+ // levels for an optional ?sku= drill-down. JSON for the bearer token.
5385
+ router.get("/admin/inventory/locations", _pageOrApi(true,
5386
+ R(async function (req, res) {
5387
+ var rows = await inventoryLocations.listLocations({});
5388
+ _json(res, 200, { rows: rows });
5389
+ }),
5390
+ async function (req, res) {
5391
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5392
+ var sku = url && url.searchParams.get("sku");
5393
+ var rows = await inventoryLocations.listLocations({});
5394
+ var levels = null;
5395
+ if (sku) {
5396
+ try { levels = await inventoryLocations.stockForSku(sku); }
5397
+ catch (e) { if (!(e instanceof TypeError)) throw e; levels = null; }
5398
+ }
5399
+ _sendHtml(res, 200, renderAdminInvLocations({
5400
+ shop_name: deps.shop_name, nav_available: navAvailable,
5401
+ locations: rows, sku: sku || "", levels: levels,
5402
+ saved: url && url.searchParams.get("saved"),
5403
+ notice: url && url.searchParams.get("err") ? "That location couldn't be saved — check the fields and try again." : null,
5404
+ }));
5405
+ },
5406
+ ));
5407
+
5408
+ // POST /admin/inventory/locations — defineLocation, or updateLocation
5409
+ // when the code already exists (operator-friendly upsert). A bad shape
5410
+ // is a plain TypeError → 400.
5411
+ router.post("/admin/inventory/locations", _pageOrApi(false,
5412
+ W("inventory.location.save", async function (req, res) {
5413
+ var loc;
5414
+ try { loc = await _saveInvLocationFromBody(req.body || {}); }
5415
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5416
+ _json(res, 201, loc);
5417
+ return loc;
5418
+ }),
5419
+ async function (req, res) {
5420
+ try { await _saveInvLocationFromBody(req.body || {}); }
5421
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/locations?err=1"); throw e; }
5422
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.location.save", outcome: "success", metadata: { code: (req.body || {}).code } });
5423
+ _redirect(res, "/admin/inventory/locations?saved=1");
5424
+ },
5425
+ ));
5426
+
5427
+ async function _saveInvLocationFromBody(body) {
5428
+ var input = {
5429
+ code: body.code,
5430
+ name: body.name,
5431
+ type: body.type,
5432
+ priority: body.priority == null || body.priority === "" ? undefined : (parseInt(body.priority, 10)),
5433
+ };
5434
+ if (input.priority !== undefined && !Number.isFinite(input.priority)) {
5435
+ throw new TypeError("admin.inventory.location.save: priority must be an integer");
5436
+ }
5437
+ var existing = await inventoryLocations.getLocation(body.code);
5438
+ if (existing) {
5439
+ var patch = { name: input.name, type: input.type };
5440
+ if (input.priority !== undefined) patch.priority = input.priority;
5441
+ return inventoryLocations.updateLocation(body.code, patch);
5442
+ }
5443
+ return inventoryLocations.defineLocation(input);
5444
+ }
5445
+
5446
+ // POST /admin/inventory/locations/:code/deactivate — soft-delete. The
5447
+ // row survives so historical adjustments still resolve their location.
5448
+ router.post("/admin/inventory/locations/:code/deactivate", _pageOrApi(false,
5449
+ W("inventory.location.deactivate", async function (req, res) {
5450
+ var code = req.params.code;
5451
+ var existing;
5452
+ try { existing = await inventoryLocations.getLocation(code); }
5453
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5454
+ if (!existing) return _problem(res, 404, "inventory-location-not-found");
5455
+ var row = await inventoryLocations.deactivateLocation(code);
5456
+ _json(res, 200, row);
5457
+ return row;
5458
+ }),
5459
+ async function (req, res) {
5460
+ var code = req.params.code;
5461
+ var existing;
5462
+ try { existing = await inventoryLocations.getLocation(code); }
5463
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/locations?err=1"); throw e; }
5464
+ if (!existing) return _redirect(res, "/admin/inventory/locations?err=1");
5465
+ await inventoryLocations.deactivateLocation(code);
5466
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.location.deactivate", outcome: "success", metadata: { code: code } });
5467
+ _redirect(res, "/admin/inventory/locations?saved=1");
5468
+ },
5469
+ ));
5470
+ }
5471
+
5472
+ if (inventoryReceive && inventoryLocations) {
5473
+ // GET /admin/inventory/receive — the receive form + recent receipt
5474
+ // history (the inventory-receive ledger). The form credits a single
5475
+ // (sku, location) line; the history reads the batched-receipt records.
5476
+ router.get("/admin/inventory/receive", _pageOrApi(true,
5477
+ R(async function (req, res) {
5478
+ var page = await inventoryReceive.list({ limit: 50 });
5479
+ _json(res, 200, { rows: page.rows, next_cursor: page.next_cursor });
5480
+ }),
5481
+ async function (req, res) {
5482
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5483
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5484
+ var page = await inventoryReceive.list({ limit: 50 });
5485
+ _sendHtml(res, 200, renderAdminInvReceive({
5486
+ shop_name: deps.shop_name, nav_available: navAvailable,
5487
+ locations: locs, receipts: page.rows || [],
5488
+ saved: url && url.searchParams.get("saved"),
5489
+ notice: url && url.searchParams.get("err") ? "That receipt couldn't be recorded — check the SKU, location, and quantity." : null,
5490
+ }));
5491
+ },
5492
+ ));
5493
+
5494
+ // POST /admin/inventory/receive — credit a single (sku, location) line
5495
+ // to both ledgers. quantity must be a positive integer; the location
5496
+ // must exist (adjustStock refuses an unknown one).
5497
+ router.post("/admin/inventory/receive", _pageOrApi(false,
5498
+ W("inventory.receive", async function (req, res) {
5499
+ var body = req.body || {};
5500
+ var qty = parseInt(body.quantity, 10);
5501
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5502
+ var out;
5503
+ try { out = await _receiveToLocation(body.sku, body.location_code, qty, body.reason); }
5504
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5505
+ _json(res, 201, out);
5506
+ return out;
5507
+ }),
5508
+ async function (req, res) {
5509
+ var body = req.body || {};
5510
+ var qty = parseInt(body.quantity, 10);
5511
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/receive?err=1");
5512
+ try { await _receiveToLocation(body.sku, body.location_code, qty, body.reason); }
5513
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/receive?err=1"); throw e; }
5514
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.receive", outcome: "success", metadata: { sku: body.sku, location_code: body.location_code, quantity: qty } });
5515
+ _redirect(res, "/admin/inventory/receive?saved=1");
5516
+ },
5517
+ ));
5518
+ }
5519
+
5520
+ if (stockTransfers && inventoryLocations) {
5521
+ // GET /admin/inventory/transfers — the open-transfer queue + the open
5522
+ // form. The queue shows non-terminal transfers with the FSM actions
5523
+ // legal from each status.
5524
+ router.get("/admin/inventory/transfers", _pageOrApi(true,
5525
+ R(async function (req, res) {
5526
+ var rows = await stockTransfers.listOpen({});
5527
+ _json(res, 200, { rows: rows });
5528
+ }),
5529
+ async function (req, res) {
5530
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5531
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5532
+ var rows = await stockTransfers.listOpen({});
5533
+ _sendHtml(res, 200, renderAdminInvTransfers({
5534
+ shop_name: deps.shop_name, nav_available: navAvailable,
5535
+ locations: locs, transfers: rows,
5536
+ saved: url && url.searchParams.get("saved"),
5537
+ notice: url && url.searchParams.get("err") ? "That transfer action couldn't be completed — check stock at the source and the transfer state." : null,
5538
+ }));
5539
+ },
5540
+ ));
5541
+
5542
+ // POST /admin/inventory/transfers — openTransfer for one (sku, qty)
5543
+ // line. Debits the source immediately (stock leaves at dispatch).
5544
+ router.post("/admin/inventory/transfers", _pageOrApi(false,
5545
+ W("inventory.transfer.open", async function (req, res) {
5546
+ var body = req.body || {};
5547
+ var qty = parseInt(body.quantity, 10);
5548
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5549
+ var t;
5550
+ try {
5551
+ t = await stockTransfers.openTransfer({
5552
+ from_location: body.from_location, to_location: body.to_location,
5553
+ lines: [{ sku: body.sku, quantity: qty }], reason: body.reason,
5554
+ });
5555
+ } catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5556
+ _json(res, 201, t);
5557
+ return t;
5558
+ }),
5559
+ async function (req, res) {
5560
+ var body = req.body || {};
5561
+ var qty = parseInt(body.quantity, 10);
5562
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/transfers?err=1");
5563
+ try {
5564
+ await stockTransfers.openTransfer({
5565
+ from_location: body.from_location, to_location: body.to_location,
5566
+ lines: [{ sku: body.sku, quantity: qty }], reason: body.reason,
5567
+ });
5568
+ } catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/transfers?err=1"); throw e; }
5569
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.transfer.open", outcome: "success", metadata: { sku: body.sku, from: body.from_location, to: body.to_location, quantity: qty } });
5570
+ _redirect(res, "/admin/inventory/transfers?saved=1");
5571
+ },
5572
+ ));
5573
+
5574
+ // The FSM-action routes: ship → in-transit → receive → reconcile, plus
5575
+ // exception. Each resolves the transfer first (unknown id → err) and
5576
+ // calls the matching primitive verb; a wrong-state transition is a
5577
+ // plain TypeError surfaced as an error redirect / 400.
5578
+ function _transferAction(action, verb) {
5579
+ router.post("/admin/inventory/transfers/:id/" + action, _pageOrApi(false,
5580
+ W("inventory.transfer." + action, async function (req, res) {
5581
+ var id = req.params.id;
5582
+ var out;
5583
+ try { out = await verb(id, req.body || {}); }
5584
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5585
+ _json(res, 200, out);
5586
+ return out;
5587
+ }),
5588
+ async function (req, res) {
5589
+ var id = req.params.id;
5590
+ try { await verb(id, req.body || {}); }
5591
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/transfers?err=1"); throw e; }
5592
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.transfer." + action, outcome: "success", metadata: { transfer_id: id } });
5593
+ _redirect(res, "/admin/inventory/transfers?saved=1");
5594
+ },
5595
+ ));
5596
+ }
5597
+ _transferAction("ship", function (id, body) {
5598
+ return stockTransfers.markShipped({ transfer_id: id, carrier: body.carrier || null, tracking_number: body.tracking_number || null });
5599
+ });
5600
+ _transferAction("in-transit", function (id, body) {
5601
+ return stockTransfers.markInTransit({ transfer_id: id, location: body.location || null });
5602
+ });
5603
+ _transferAction("receive", function (id, body) {
5604
+ // Single-line transfers from the open form: receive the full shipped
5605
+ // qty unless the operator overrides quantity_received. Resolve the
5606
+ // shipped line so a blank field receives everything (the happy path).
5607
+ return (async function () {
5608
+ var t = await stockTransfers.getTransfer(id);
5609
+ if (!t) throw new TypeError("transfer not found");
5610
+ var rx = t.lines.map(function (l) {
5611
+ var got = body["rx_" + l.sku];
5612
+ var q = got == null || got === "" ? l.quantity_shipped : parseInt(got, 10);
5613
+ if (!Number.isInteger(q) || q < 0) throw new TypeError("quantity_received must be a non-negative integer");
5614
+ return { sku: l.sku, quantity_received: q };
5615
+ });
5616
+ return stockTransfers.markReceived({ transfer_id: id, received_lines: rx });
5617
+ })();
5618
+ });
5619
+ _transferAction("reconcile", function (id) {
5620
+ return stockTransfers.reconcile({ transfer_id: id });
5621
+ });
5622
+ _transferAction("exception", function (id, body) {
5623
+ return stockTransfers.markException({ transfer_id: id, reason: body.reason });
5624
+ });
5625
+ }
5626
+
5627
+ if (inventoryWriteoffs && inventoryLocations) {
5628
+ // GET /admin/inventory/writeoffs — the write-off log + the record form.
5629
+ router.get("/admin/inventory/writeoffs", _pageOrApi(true,
5630
+ R(async function (req, res) {
5631
+ var page = await inventoryWriteoffs.listWriteoffs({ limit: 50 });
5632
+ _json(res, 200, { rows: page.rows, next_cursor: page.next_cursor });
5633
+ }),
5634
+ async function (req, res) {
5635
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5636
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5637
+ var page = await inventoryWriteoffs.listWriteoffs({ limit: 50 });
5638
+ _sendHtml(res, 200, renderAdminInvWriteoffs({
5639
+ shop_name: deps.shop_name, nav_available: navAvailable,
5640
+ locations: locs, writeoffs: page.rows || [],
5641
+ reasons: inventoryWriteoffsModule.WRITEOFF_REASONS,
5642
+ saved: url && url.searchParams.get("saved"),
5643
+ notice: url && url.searchParams.get("err") ? "That write-off couldn't be recorded — check the SKU, location, quantity, and reason." : null,
5644
+ }));
5645
+ },
5646
+ ));
5647
+
5648
+ // POST /admin/inventory/writeoffs — recordWriteoff against a location +
5649
+ // mirror the debit onto the storefront aggregate.
5650
+ router.post("/admin/inventory/writeoffs", _pageOrApi(false,
5651
+ W("inventory.writeoff", async function (req, res) {
5652
+ var body = req.body || {};
5653
+ var qty = parseInt(body.quantity, 10);
5654
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5655
+ var row;
5656
+ try {
5657
+ row = await _writeoffFromLocation({
5658
+ sku: body.sku, location_code: body.location_code, quantity: qty,
5659
+ reason: body.reason, actor: body.actor || "admin-console", notes: body.notes || null,
5660
+ });
5661
+ } catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5662
+ _json(res, 201, row);
5663
+ return row;
5664
+ }),
5665
+ async function (req, res) {
5666
+ var body = req.body || {};
5667
+ var qty = parseInt(body.quantity, 10);
5668
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/writeoffs?err=1");
5669
+ try {
5670
+ await _writeoffFromLocation({
5671
+ sku: body.sku, location_code: body.location_code, quantity: qty,
5672
+ reason: body.reason, actor: body.actor || "admin-console", notes: body.notes || null,
5673
+ });
5674
+ } catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/writeoffs?err=1"); throw e; }
5675
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.writeoff", outcome: "success", metadata: { sku: body.sku, location_code: body.location_code, quantity: qty, reason: body.reason } });
5676
+ _redirect(res, "/admin/inventory/writeoffs?saved=1");
5677
+ },
5678
+ ));
5679
+ }
5680
+
5276
5681
  // ---- gift wraps -----------------------------------------------------
5277
5682
  //
5278
5683
  // The operator-defined gift-wrap catalog: define / update / archive a wrap
@@ -11946,6 +12351,10 @@ var ADMIN_NAV_ITEMS = [
11946
12351
  { key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
11947
12352
  { key: "products", href: "/admin/products", label: "Products" },
11948
12353
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
12354
+ { key: "inventory-locations", href: "/admin/inventory/locations", label: "Stock locations", requires: "inventoryLocations" },
12355
+ { key: "inventory-receive", href: "/admin/inventory/receive", label: "Receive stock", requires: "inventoryReceive" },
12356
+ { key: "inventory-transfers", href: "/admin/inventory/transfers", label: "Transfers", requires: "stockTransfers" },
12357
+ { key: "inventory-writeoffs", href: "/admin/inventory/writeoffs", label: "Write-offs", requires: "inventoryWriteoffs" },
11949
12358
  { key: "orders", href: "/admin/orders", label: "Orders" },
11950
12359
  { key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
11951
12360
  { key: "reports", href: "/admin/reports", label: "Reports" },
@@ -14687,6 +15096,236 @@ function renderAdminPickupLocations(opts) {
14687
15096
  return _renderAdminShell(opts.shop_name, "Pickup locations", body, "pickup-locations", opts.nav_available);
14688
15097
  }
14689
15098
 
15099
+ // Stock-location list + the define/update form + an optional per-SKU
15100
+ // levels drill-down. Location names and codes are operator free text —
15101
+ // escaped at the sink.
15102
+ function renderAdminInvLocations(opts) {
15103
+ opts = opts || {};
15104
+ var list = opts.locations || [];
15105
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Stock location saved.</div>" : "";
15106
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15107
+
15108
+ var rows = list.map(function (l) {
15109
+ return "<tr>" +
15110
+ "<td><code class=\"order-id\">" + _htmlEscape(l.code) + "</code></td>" +
15111
+ "<td>" + _htmlEscape(l.name) + "</td>" +
15112
+ "<td>" + _htmlEscape(l.type) + "</td>" +
15113
+ "<td>" + _htmlEscape(String(l.priority)) + "</td>" +
15114
+ "<td>" + (l.active ? "<span class=\"status-pill\">active</span>" : "<span class=\"meta\">inactive</span>") + "</td>" +
15115
+ "<td>" +
15116
+ (l.active
15117
+ ? "<form method=\"post\" action=\"/admin/inventory/locations/" + encodeURIComponent(l.code) + "/deactivate\" class=\"form-inline\">" +
15118
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Deactivate</button></form>"
15119
+ : "<span class=\"meta\">—</span>") +
15120
+ "</td>" +
15121
+ "</tr>";
15122
+ }).join("");
15123
+
15124
+ var table = list.length
15125
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Code</th><th scope=\"col\">Name</th><th scope=\"col\">Type</th><th scope=\"col\">Priority</th><th scope=\"col\">Status</th><th scope=\"col\">Action</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15126
+ : "<p class=\"empty\">No stock locations yet. A store with one warehouse needs no location — the default bucket is implicit. Add a location below once you ship from more than one place.</p>";
15127
+
15128
+ // Per-SKU levels drill-down.
15129
+ var levelsBlock = "";
15130
+ if (opts.levels) {
15131
+ var lv = opts.levels;
15132
+ var lvRows = (lv.by_location || []).map(function (r) {
15133
+ return "<tr><td><code class=\"order-id\">" + _htmlEscape(r.code) + "</code></td><td>" + _htmlEscape(String(r.quantity)) + "</td></tr>";
15134
+ }).join("");
15135
+ levelsBlock =
15136
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Levels for " + _htmlEscape(opts.sku) + " — total " + _htmlEscape(String(lv.total)) + "</h3>" +
15137
+ (lv.by_location && lv.by_location.length
15138
+ ? _tableWrap("<table><thead><tr><th scope=\"col\">Location</th><th scope=\"col\">On hand</th></tr></thead><tbody>" + lvRows + "</tbody></table>")
15139
+ : "<p class=\"empty\">No per-location stock recorded for this SKU.</p>") +
15140
+ "</div>";
15141
+ }
15142
+ var lookup =
15143
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Per-location levels</h3>" +
15144
+ "<form method=\"get\" action=\"/admin/inventory/locations\" class=\"form-inline\">" +
15145
+ _setupField("SKU", "sku", opts.sku || "", "text", "Show the per-location breakdown for one SKU.", " maxlength=\"128\"") +
15146
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Look up</button>" +
15147
+ "</form>" + levelsBlock +
15148
+ "</div>";
15149
+
15150
+ var form =
15151
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Add / update a stock location</h3>" +
15152
+ "<form method=\"post\" action=\"/admin/inventory/locations\">" +
15153
+ _setupField("Code", "code", "", "text", "Stable identifier (alnum + . _ -). Re-using a code updates that location.", " maxlength=\"64\" required") +
15154
+ _setupField("Name", "name", "", "text", "Operator-facing label (e.g. East warehouse).", " maxlength=\"128\" required") +
15155
+ "<label class=\"form-field\"><span>Type</span>" +
15156
+ "<select name=\"type\" required>" +
15157
+ "<option value=\"warehouse\">warehouse</option>" +
15158
+ "<option value=\"retail\">retail</option>" +
15159
+ "<option value=\"dropship\">dropship</option>" +
15160
+ "</select>" +
15161
+ "<small>Drives default routing weight; override with priority.</small>" +
15162
+ "</label>" +
15163
+ _setupField("Priority", "priority", "", "number", "Lower picks first (default 100).", " min=\"0\"") +
15164
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save location</button></div>" +
15165
+ "</form>" +
15166
+ "</div>";
15167
+
15168
+ var body = "<section><h2>Stock locations</h2>" + saved + notice + table + lookup + form + "</section>";
15169
+ return _renderAdminShell(opts.shop_name, "Stock locations", body, "inventory-locations", opts.nav_available);
15170
+ }
15171
+
15172
+ // Receive inbound stock against a location. Credits both the per-location
15173
+ // detail and the storefront aggregate. Recent receipts (the batched
15174
+ // inventory-receive ledger) render below the form. Reference / supplier
15175
+ // are operator free text — escaped at the sink.
15176
+ function renderAdminInvReceive(opts) {
15177
+ opts = opts || {};
15178
+ var locs = opts.locations || [];
15179
+ var receipts = opts.receipts || [];
15180
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Stock received.</div>" : "";
15181
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15182
+
15183
+ var locOptions = locs.map(function (l) {
15184
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15185
+ }).join("");
15186
+
15187
+ var form = locs.length
15188
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Receive stock</h3>" +
15189
+ "<form method=\"post\" action=\"/admin/inventory/receive\">" +
15190
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15191
+ "<label class=\"form-field\"><span>Location</span><select name=\"location_code\" required>" + locOptions + "</select></label>" +
15192
+ _setupField("Quantity", "quantity", "", "number", "Units received (positive).", " min=\"1\" required") +
15193
+ _setupField("Reason / reference", "reason", "", "text", "Optional — PO number, supplier, note.", " maxlength=\"256\"") +
15194
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Receive</button></div>" +
15195
+ "</form></div>"
15196
+ : "<p class=\"empty\">Add a stock location first, then receive stock against it.</p>";
15197
+
15198
+ var recRows = receipts.map(function (r) {
15199
+ return "<tr>" +
15200
+ "<td>" + _htmlEscape(String(r.reference || "")) + "</td>" +
15201
+ "<td>" + _htmlEscape(String(r.supplier || "")) + "</td>" +
15202
+ "<td><span class=\"status-pill\">" + _htmlEscape(String(r.status)) + "</span></td>" +
15203
+ "<td>" + _htmlEscape(String(r.total_qty)) + "</td>" +
15204
+ "<td>" + _htmlEscape(_fmtDate(r.received_at)) + "</td>" +
15205
+ "</tr>";
15206
+ }).join("");
15207
+ var history = receipts.length
15208
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Recent receipts</h3>" +
15209
+ _tableWrap("<table><thead><tr><th scope=\"col\">Reference</th><th scope=\"col\">Supplier</th><th scope=\"col\">Status</th><th scope=\"col\">Qty</th><th scope=\"col\">Received</th></tr></thead><tbody>" + recRows + "</tbody></table>") +
15210
+ "</div>"
15211
+ : "";
15212
+
15213
+ var body = "<section><h2>Receive stock</h2>" + saved + notice + form + history + "</section>";
15214
+ return _renderAdminShell(opts.shop_name, "Receive stock", body, "inventory-receive", opts.nav_available);
15215
+ }
15216
+
15217
+ // Location→location transfer console: the open form + the open-transfer
15218
+ // queue with the FSM action legal from each row's status. Reasons,
15219
+ // carriers, and tracking numbers are operator free text — escaped.
15220
+ function renderAdminInvTransfers(opts) {
15221
+ opts = opts || {};
15222
+ var locs = opts.locations || [];
15223
+ var transfers = opts.transfers || [];
15224
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Transfer updated.</div>" : "";
15225
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15226
+
15227
+ var locOptions = locs.map(function (l) {
15228
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15229
+ }).join("");
15230
+
15231
+ function _transferActions(t) {
15232
+ var blocks = [];
15233
+ var act = function (action, label, extra) {
15234
+ return "<form method=\"post\" action=\"/admin/inventory/transfers/" + encodeURIComponent(t.id) + "/" + action + "\" class=\"form-inline\">" +
15235
+ (extra || "") + "<button class=\"btn btn--sm\" type=\"submit\">" + _htmlEscape(label) + "</button></form>";
15236
+ };
15237
+ if (t.status === "open") blocks.push(act("ship", "Mark shipped"));
15238
+ if (t.status === "shipped" || t.status === "in_transit") {
15239
+ blocks.push(act("receive", "Mark received"));
15240
+ }
15241
+ if (t.status === "received") blocks.push(act("reconcile", "Reconcile"));
15242
+ if (t.status !== "reconciled" && t.status !== "exception") {
15243
+ blocks.push(act("exception", "Exception",
15244
+ "<input type=\"text\" name=\"reason\" placeholder=\"reason\" maxlength=\"280\" required>"));
15245
+ }
15246
+ return blocks.length ? blocks.join("") : "<span class=\"meta\">—</span>";
15247
+ }
15248
+
15249
+ var rows = transfers.map(function (t) {
15250
+ var lineSummary = (t.lines || []).map(function (l) {
15251
+ return _htmlEscape(l.sku) + "×" + _htmlEscape(String(l.quantity_shipped));
15252
+ }).join(", ");
15253
+ return "<tr>" +
15254
+ "<td><code class=\"order-id\">" + _htmlEscape(String(t.id).slice(0, 8)) + "</code></td>" +
15255
+ "<td>" + _htmlEscape(t.from_location) + " → " + _htmlEscape(t.to_location) + "</td>" +
15256
+ "<td>" + lineSummary + "</td>" +
15257
+ "<td><span class=\"status-pill\">" + _htmlEscape(t.status) + "</span></td>" +
15258
+ "<td>" + _transferActions(t) + "</td>" +
15259
+ "</tr>";
15260
+ }).join("");
15261
+
15262
+ var queue = transfers.length
15263
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">ID</th><th scope=\"col\">Route</th><th scope=\"col\">Lines</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15264
+ : "<p class=\"empty\">No open transfers.</p>";
15265
+
15266
+ var form = locs.length >= 2
15267
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Open a transfer</h3>" +
15268
+ "<form method=\"post\" action=\"/admin/inventory/transfers\">" +
15269
+ "<label class=\"form-field\"><span>From</span><select name=\"from_location\" required>" + locOptions + "</select></label>" +
15270
+ "<label class=\"form-field\"><span>To</span><select name=\"to_location\" required>" + locOptions + "</select></label>" +
15271
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15272
+ _setupField("Quantity", "quantity", "", "number", "Units to move (leaves the source immediately).", " min=\"1\" required") +
15273
+ _setupField("Reason", "reason", "", "text", "Optional note.", " maxlength=\"280\"") +
15274
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Open transfer</button></div>" +
15275
+ "</form></div>"
15276
+ : "<p class=\"empty\">Define at least two stock locations to transfer between them.</p>";
15277
+
15278
+ var body = "<section><h2>Stock transfers</h2>" + saved + notice + queue + form + "</section>";
15279
+ return _renderAdminShell(opts.shop_name, "Transfers", body, "inventory-transfers", opts.nav_available);
15280
+ }
15281
+
15282
+ // Reason-coded write-off log + record form. SKU, reason, notes, and actor
15283
+ // are operator free text — escaped at the sink.
15284
+ function renderAdminInvWriteoffs(opts) {
15285
+ opts = opts || {};
15286
+ var locs = opts.locations || [];
15287
+ var list = opts.writeoffs || [];
15288
+ var reasons = opts.reasons || [];
15289
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Write-off recorded.</div>" : "";
15290
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15291
+
15292
+ var locOptions = locs.map(function (l) {
15293
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15294
+ }).join("");
15295
+ var reasonOptions = reasons.map(function (r) {
15296
+ return "<option value=\"" + _htmlEscape(r) + "\">" + _htmlEscape(r) + "</option>";
15297
+ }).join("");
15298
+
15299
+ var rows = list.map(function (w) {
15300
+ return "<tr>" +
15301
+ "<td>" + _htmlEscape(String(w.sku)) + "</td>" +
15302
+ "<td>" + (w.location_code ? "<code class=\"order-id\">" + _htmlEscape(String(w.location_code)) + "</code>" : "<span class=\"meta\">—</span>") + "</td>" +
15303
+ "<td>" + _htmlEscape(String(w.quantity)) + "</td>" +
15304
+ "<td>" + _htmlEscape(String(w.reason)) + "</td>" +
15305
+ "<td><span class=\"status-pill\">" + _htmlEscape(String(w.status)) + "</span></td>" +
15306
+ "<td>" + _htmlEscape(_fmtDate(w.occurred_at)) + "</td>" +
15307
+ "</tr>";
15308
+ }).join("");
15309
+ var table = list.length
15310
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\">Location</th><th scope=\"col\">Qty</th><th scope=\"col\">Reason</th><th scope=\"col\">Status</th><th scope=\"col\">When</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15311
+ : "<p class=\"empty\">No write-offs recorded.</p>";
15312
+
15313
+ var form = locs.length
15314
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Record a write-off</h3>" +
15315
+ "<form method=\"post\" action=\"/admin/inventory/writeoffs\">" +
15316
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15317
+ "<label class=\"form-field\"><span>Location</span><select name=\"location_code\" required>" + locOptions + "</select></label>" +
15318
+ _setupField("Quantity", "quantity", "", "number", "Units removed (positive).", " min=\"1\" required") +
15319
+ "<label class=\"form-field\"><span>Reason</span><select name=\"reason\" required>" + reasonOptions + "</select></label>" +
15320
+ _setupField("Notes", "notes", "", "text", "Optional.", " maxlength=\"4096\"") +
15321
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn btn--danger\">Record write-off</button></div>" +
15322
+ "</form></div>"
15323
+ : "<p class=\"empty\">Add a stock location first, then record write-offs against it.</p>";
15324
+
15325
+ var body = "<section><h2>Write-offs</h2>" + saved + notice + table + form + "</section>";
15326
+ return _renderAdminShell(opts.shop_name, "Write-offs", body, "inventory-writeoffs", opts.nav_available);
15327
+ }
15328
+
14690
15329
  // Pickup queue for one location: a location selector + status chips, the
14691
15330
  // scheduled/ready rows, and the FSM action forms (ready / picked-up /
14692
15331
  // no-show) legal from each row's status. The no_show reason is operator-