@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/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/lib/admin.js +640 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +51 -1
- package/lib/inventory-locations.js +103 -66
- package/lib/security-middleware.js +28 -1
- package/lib/storefront.js +112 -48
- package/lib/webhooks.js +4 -2
- package/package.json +1 -1
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-
|