@blamejs/blamejs-shop 0.0.65 → 0.0.70
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 +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.inventoryWriteoffs
|
|
4
|
+
* @title Inventory write-offs — operator-recorded stock removal for
|
|
5
|
+
* damage / loss / shrinkage / expiry / recall / sample / QC /
|
|
6
|
+
* theft
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* The shelf decrements every time a unit leaves the building. Most
|
|
10
|
+
* exits go through a sale (the checkout primitive composes
|
|
11
|
+
* inventoryLocations.adjustStock with the order id as the audit
|
|
12
|
+
* reason). The remaining exits — broken-on-receipt damage, expired
|
|
13
|
+
* yogurt, samples handed to an influencer, the pallet that walked
|
|
14
|
+
* off the loading dock — need an operator-attested record so the
|
|
15
|
+
* shrinkage report has a paper trail and the COGS attribution
|
|
16
|
+
* doesn't silently leak into a generic "adjustment".
|
|
17
|
+
*
|
|
18
|
+
* This primitive is the verb for that record. One row per
|
|
19
|
+
* write-off, eight enumerated reasons, optional location-scope, and
|
|
20
|
+
* two composition seams: inventoryLocations (always wired — the
|
|
21
|
+
* primitive refuses without it because every write-off MUST debit
|
|
22
|
+
* the shelf) and costLayers (optional — wired by operators running
|
|
23
|
+
* COGS reporting so the cost-impact column carries a real number).
|
|
24
|
+
*
|
|
25
|
+
* Surface:
|
|
26
|
+
*
|
|
27
|
+
* recordWriteoff({ sku, location_code?, quantity, reason, actor,
|
|
28
|
+
* notes?, occurred_at? })
|
|
29
|
+
* Validates input + checks the reason is in the enumerated set
|
|
30
|
+
* above. Debits stock via `inventoryLocations.adjustStock(-qty)`
|
|
31
|
+
* (refuses upstream if the shelf doesn't have enough). When
|
|
32
|
+
* costLayers is wired, also calls `costLayers.consumeForSale`
|
|
33
|
+
* with a synthetic order_id (`writeoff:<id>`) + line_id (`1`)
|
|
34
|
+
* so the destroyed stock attributes against existing cost
|
|
35
|
+
* layers — operators reading COGS-for-period reports see the
|
|
36
|
+
* shrinkage cost surface there too. The cost-impact value
|
|
37
|
+
* lands on the writeoff row so the dashboard joins one table
|
|
38
|
+
* to render the loss dollar figure. When costLayers refuses
|
|
39
|
+
* (no on-hand layers, currency drift), the inventory debit is
|
|
40
|
+
* reversed so the shelf doesn't disagree with the write-off
|
|
41
|
+
* record — operators see a clean refusal rather than a half-
|
|
42
|
+
* committed row.
|
|
43
|
+
*
|
|
44
|
+
* getWriteoff(id)
|
|
45
|
+
* Hydrated row or null on miss. guardUuid validates shape.
|
|
46
|
+
*
|
|
47
|
+
* listWriteoffs({ from?, to?, reason?, location_code?, limit?,
|
|
48
|
+
* cursor? })
|
|
49
|
+
* Paginated read. Defaults to occurred_at DESC ordering.
|
|
50
|
+
* Optional filters compose with AND. Cursor is HMAC-tagged so
|
|
51
|
+
* an operator can't tamper with it.
|
|
52
|
+
*
|
|
53
|
+
* costImpactForPeriod({ from, to, reason? })
|
|
54
|
+
* Sums `cost_impact_minor` across non-reversed rows whose
|
|
55
|
+
* occurred_at falls in `[from, to)`. Returns per-reason +
|
|
56
|
+
* grand-total. Refuses when the in-period rows span multiple
|
|
57
|
+
* currencies — the operator must reconcile (mixed-currency
|
|
58
|
+
* totals are meaningless without an FX conversion the
|
|
59
|
+
* primitive doesn't own).
|
|
60
|
+
*
|
|
61
|
+
* reverseWriteoff({ id, reason })
|
|
62
|
+
* Restores stock via `inventoryLocations.adjustStock(+qty)`.
|
|
63
|
+
* When costLayers was wired AND a cost-impact was recorded,
|
|
64
|
+
* composes `costLayers.recordReversal({ order_id, line_id })`
|
|
65
|
+
* so the cost layer pool gets the unit back at its original
|
|
66
|
+
* per-unit cost. Marks the row `status='reversed'` with the
|
|
67
|
+
* operator-supplied reason + a reversed_at timestamp. Refuses
|
|
68
|
+
* on already-reversed rows (no double-reverse).
|
|
69
|
+
*
|
|
70
|
+
* Composition:
|
|
71
|
+
* - b.uuid.v7 — writeoff PK (sortable UUID v7)
|
|
72
|
+
* - b.guardUuid — strict UUID validation on every id
|
|
73
|
+
* - b.pagination — HMAC-tagged cursor for listWriteoffs
|
|
74
|
+
* - inventoryLocations — sole owner of inventory_stock
|
|
75
|
+
* mutation; this primitive composes
|
|
76
|
+
* adjustStock to debit / restore.
|
|
77
|
+
* - costLayers (optional) — when present, owns the cost-impact
|
|
78
|
+
* accounting.
|
|
79
|
+
*
|
|
80
|
+
* Strict-monotonic clock: per-SKU `occurred_at` is bumped to
|
|
81
|
+
* `prior + 1` when the operator-supplied (or `Date.now()`) value
|
|
82
|
+
* would tie or land older than the most recent write-off for the
|
|
83
|
+
* same SKU. Two write-offs in the same millisecond don't collide
|
|
84
|
+
* on the timeline — `listWriteoffs` ordering is unambiguous.
|
|
85
|
+
*
|
|
86
|
+
* Three-tier input validation (use the discipline; don't write the
|
|
87
|
+
* labels): every public verb is a config-time entry point OR a
|
|
88
|
+
* defensive request-shape reader. Both throw on bad input. No
|
|
89
|
+
* drop-silent hot-path sinks.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
var bShop;
|
|
93
|
+
function _b() {
|
|
94
|
+
if (!bShop) bShop = require("./index");
|
|
95
|
+
return bShop.framework;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- constants ----------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
101
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
102
|
+
var ACTOR_RE = /^[\S\s]{1,256}$/;
|
|
103
|
+
var PRINTABLE_RE = /^[^\x00-\x08\x0b\x0c\x0e-\x1f\x7f]+$/;
|
|
104
|
+
var MAX_NOTES = 4096;
|
|
105
|
+
var MAX_REASON_TEXT = 280;
|
|
106
|
+
var MAX_LIST_LIMIT = 200;
|
|
107
|
+
|
|
108
|
+
var WRITEOFF_REASONS = Object.freeze([
|
|
109
|
+
"damaged", "lost", "shrinkage", "expired",
|
|
110
|
+
"recall", "sample", "quality_control", "theft",
|
|
111
|
+
]);
|
|
112
|
+
var WRITEOFF_STATUSES = Object.freeze(["recorded", "reversed"]);
|
|
113
|
+
var WRITEOFF_ORDER_KEY = ["occurred_at:desc", "id:desc"];
|
|
114
|
+
|
|
115
|
+
// ---- validators ---------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function _id(s, label) {
|
|
118
|
+
try {
|
|
119
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw new TypeError("inventory-writeoffs: " + (label || "id") +
|
|
122
|
+
" — " + (e && e.message || "invalid UUID"));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function _sku(s) {
|
|
126
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
127
|
+
throw new TypeError("inventory-writeoffs: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
128
|
+
}
|
|
129
|
+
return s;
|
|
130
|
+
}
|
|
131
|
+
function _code(s, label) {
|
|
132
|
+
if (typeof s !== "string" || !CODE_RE.test(s)) {
|
|
133
|
+
throw new TypeError("inventory-writeoffs: " + (label || "location_code") +
|
|
134
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
|
|
135
|
+
}
|
|
136
|
+
return s;
|
|
137
|
+
}
|
|
138
|
+
function _optCode(s, label) {
|
|
139
|
+
if (s == null) return null;
|
|
140
|
+
return _code(s, label);
|
|
141
|
+
}
|
|
142
|
+
function _positiveInt(n, label) {
|
|
143
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
144
|
+
throw new TypeError("inventory-writeoffs: " + label + " must be a positive integer");
|
|
145
|
+
}
|
|
146
|
+
return n;
|
|
147
|
+
}
|
|
148
|
+
function _reason(s) {
|
|
149
|
+
if (typeof s !== "string" || WRITEOFF_REASONS.indexOf(s) === -1) {
|
|
150
|
+
throw new TypeError("inventory-writeoffs: reason must be one of " +
|
|
151
|
+
WRITEOFF_REASONS.join(", ") + ", got " + JSON.stringify(s));
|
|
152
|
+
}
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
function _actor(s) {
|
|
156
|
+
if (typeof s !== "string" || !ACTOR_RE.test(s) || s.length > 256) {
|
|
157
|
+
throw new TypeError("inventory-writeoffs: actor must be a non-empty string ≤ 256 chars");
|
|
158
|
+
}
|
|
159
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
160
|
+
throw new TypeError("inventory-writeoffs: actor must not contain control bytes other than tab/newline/CR");
|
|
161
|
+
}
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
function _notes(s) {
|
|
165
|
+
if (s == null) return null;
|
|
166
|
+
if (typeof s !== "string") {
|
|
167
|
+
throw new TypeError("inventory-writeoffs: notes must be a string or null");
|
|
168
|
+
}
|
|
169
|
+
if (s.length > MAX_NOTES) {
|
|
170
|
+
throw new TypeError("inventory-writeoffs: notes must be ≤ " + MAX_NOTES + " chars");
|
|
171
|
+
}
|
|
172
|
+
if (s.length && !PRINTABLE_RE.test(s)) {
|
|
173
|
+
throw new TypeError("inventory-writeoffs: notes must not contain control bytes other than tab/newline/CR");
|
|
174
|
+
}
|
|
175
|
+
return s;
|
|
176
|
+
}
|
|
177
|
+
function _reverseReason(s) {
|
|
178
|
+
if (typeof s !== "string" || !s.length) {
|
|
179
|
+
throw new TypeError("inventory-writeoffs: reverse reason must be a non-empty string");
|
|
180
|
+
}
|
|
181
|
+
if (s.length > MAX_REASON_TEXT) {
|
|
182
|
+
throw new TypeError("inventory-writeoffs: reverse reason must be ≤ " + MAX_REASON_TEXT + " chars");
|
|
183
|
+
}
|
|
184
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
185
|
+
throw new TypeError("inventory-writeoffs: reverse reason must not contain control bytes other than tab/newline/CR");
|
|
186
|
+
}
|
|
187
|
+
return s;
|
|
188
|
+
}
|
|
189
|
+
function _epochMs(ts, label) {
|
|
190
|
+
if (ts == null) return null;
|
|
191
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
192
|
+
throw new TypeError("inventory-writeoffs: " + label + " must be a non-negative integer epoch-ms");
|
|
193
|
+
}
|
|
194
|
+
return ts;
|
|
195
|
+
}
|
|
196
|
+
function _limit(n) {
|
|
197
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
198
|
+
throw new TypeError("inventory-writeoffs: limit must be an integer in 1..." + MAX_LIST_LIMIT);
|
|
199
|
+
}
|
|
200
|
+
return n;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _now() { return Date.now(); }
|
|
204
|
+
|
|
205
|
+
// ---- factory ------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function create(opts) {
|
|
208
|
+
opts = opts || {};
|
|
209
|
+
// inventoryLocations is mandatory — every write-off MUST debit the
|
|
210
|
+
// shelf, so wiring without it is a misconfigured operator boot.
|
|
211
|
+
if (!opts.inventoryLocations ||
|
|
212
|
+
typeof opts.inventoryLocations.adjustStock !== "function") {
|
|
213
|
+
throw new TypeError("inventory-writeoffs.create: opts.inventoryLocations with adjustStock is required");
|
|
214
|
+
}
|
|
215
|
+
var locations = opts.inventoryLocations;
|
|
216
|
+
// costLayers is optional — when wired, every recordWriteoff also
|
|
217
|
+
// calls consumeForSale to attribute COGS-equivalent cost; without
|
|
218
|
+
// it, cost_impact_minor stays NULL and costImpactForPeriod returns
|
|
219
|
+
// a zero-total for the period.
|
|
220
|
+
var costLayers = opts.costLayers != null ? opts.costLayers : null;
|
|
221
|
+
if (costLayers !== null) {
|
|
222
|
+
if (typeof costLayers.consumeForSale !== "function" ||
|
|
223
|
+
typeof costLayers.recordReversal !== "function") {
|
|
224
|
+
throw new TypeError("inventory-writeoffs.create: opts.costLayers must expose consumeForSale + recordReversal when wired");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
var query = opts.query;
|
|
228
|
+
if (!query) {
|
|
229
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
230
|
+
}
|
|
231
|
+
// Pagination cursors are HMAC-tagged so an operator can't tamper or
|
|
232
|
+
// replay across deployments. Tests inject a fixed dev string;
|
|
233
|
+
// production must supply an explicit secret.
|
|
234
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
235
|
+
if (process.env.NODE_ENV === "production") {
|
|
236
|
+
throw new Error("inventory-writeoffs.create: opts.cursorSecret is required in production");
|
|
237
|
+
}
|
|
238
|
+
opts.cursorSecret = "inventory-writeoffs-cursor-secret-dev-only";
|
|
239
|
+
}
|
|
240
|
+
var cursorSecret = opts.cursorSecret;
|
|
241
|
+
|
|
242
|
+
// Latest occurred_at for a SKU; null when the SKU has no prior
|
|
243
|
+
// write-off. Used by the strict-monotonic clock so two write-offs
|
|
244
|
+
// in the same millisecond don't tie on the timeline ordering key.
|
|
245
|
+
async function _latestWriteoffTs(sku) {
|
|
246
|
+
var r = await query(
|
|
247
|
+
"SELECT MAX(occurred_at) AS ts FROM inventory_writeoffs WHERE sku = ?1",
|
|
248
|
+
[sku],
|
|
249
|
+
);
|
|
250
|
+
if (!r.rows.length || r.rows[0].ts == null) return null;
|
|
251
|
+
return r.rows[0].ts;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _resolveOccurredAt(requestedTs, latestTs) {
|
|
255
|
+
if (latestTs == null) return requestedTs;
|
|
256
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
257
|
+
return latestTs + 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function _getWriteoffRow(id) {
|
|
261
|
+
var r = await query("SELECT * FROM inventory_writeoffs WHERE id = ?1", [id]);
|
|
262
|
+
if (!r.rows.length) return null;
|
|
263
|
+
return r.rows[0];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
|
|
268
|
+
// Record a write-off. Validates input, debits the shelf via
|
|
269
|
+
// inventoryLocations.adjustStock, and (when costLayers is wired)
|
|
270
|
+
// composes consumeForSale so the cost-impact column carries a
|
|
271
|
+
// real COGS-equivalent value. Returns the hydrated row.
|
|
272
|
+
recordWriteoff: async function (input) {
|
|
273
|
+
if (!input || typeof input !== "object") {
|
|
274
|
+
throw new TypeError("inventory-writeoffs.recordWriteoff: input object required");
|
|
275
|
+
}
|
|
276
|
+
var sku = _sku(input.sku);
|
|
277
|
+
var locCode = _optCode(input.location_code, "location_code");
|
|
278
|
+
var quantity = _positiveInt(input.quantity, "quantity");
|
|
279
|
+
var reason = _reason(input.reason);
|
|
280
|
+
var actor = _actor(input.actor);
|
|
281
|
+
var notes = _notes(input.notes);
|
|
282
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
283
|
+
if (requested == null) requested = _now();
|
|
284
|
+
var latestTs = await _latestWriteoffTs(sku);
|
|
285
|
+
var occurredAt = _resolveOccurredAt(requested, latestTs);
|
|
286
|
+
|
|
287
|
+
var id = _b().uuid.v7();
|
|
288
|
+
|
|
289
|
+
// Debit the shelf first. When location_code is null we don't
|
|
290
|
+
// touch a specific shelf — the operator is recording a "global"
|
|
291
|
+
// write-off (catalog-level shrinkage adjustment with no per-
|
|
292
|
+
// location accounting). When location_code is set, the
|
|
293
|
+
// adjustStock refuses if the shelf doesn't have enough; that
|
|
294
|
+
// refusal propagates with no compensating action needed
|
|
295
|
+
// because no row has been written yet.
|
|
296
|
+
if (locCode !== null) {
|
|
297
|
+
await locations.adjustStock({
|
|
298
|
+
sku: sku,
|
|
299
|
+
location_code: locCode,
|
|
300
|
+
delta: -quantity,
|
|
301
|
+
reason: "writeoff:" + reason + ":" + id,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Attribute COGS-equivalent cost when costLayers is wired. The
|
|
306
|
+
// synthetic order_id `writeoff:<id>` keeps the cost-layer
|
|
307
|
+
// attribution discoverable from this primitive (reverseWriteoff
|
|
308
|
+
// composes recordReversal with the same id pair to restore
|
|
309
|
+
// layers cleanly).
|
|
310
|
+
var costImpactMinor = null;
|
|
311
|
+
var currency = null;
|
|
312
|
+
if (costLayers !== null) {
|
|
313
|
+
try {
|
|
314
|
+
var consumed = await costLayers.consumeForSale({
|
|
315
|
+
sku: sku,
|
|
316
|
+
quantity: quantity,
|
|
317
|
+
order_id: "writeoff:" + id,
|
|
318
|
+
line_id: "1",
|
|
319
|
+
occurred_at: occurredAt,
|
|
320
|
+
});
|
|
321
|
+
costImpactMinor = consumed.total_cogs_minor;
|
|
322
|
+
currency = consumed.currency;
|
|
323
|
+
} catch (e) {
|
|
324
|
+
// The cost-layer pool refused (no on-hand layers yet, or
|
|
325
|
+
// currency drift across layers). Restore the shelf debit
|
|
326
|
+
// so the storefront doesn't disagree with the write-off
|
|
327
|
+
// record, then surface the original refusal — the operator
|
|
328
|
+
// either records a receipt first or reconciles currency
|
|
329
|
+
// before retrying.
|
|
330
|
+
if (locCode !== null) {
|
|
331
|
+
try {
|
|
332
|
+
await locations.adjustStock({
|
|
333
|
+
sku: sku,
|
|
334
|
+
location_code: locCode,
|
|
335
|
+
delta: quantity,
|
|
336
|
+
reason: "writeoff:rollback:" + id,
|
|
337
|
+
});
|
|
338
|
+
} catch (_e2) { /* drop-silent — the original cost-layer error is the operator's signal */ }
|
|
339
|
+
}
|
|
340
|
+
throw e;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
await query(
|
|
346
|
+
"INSERT INTO inventory_writeoffs " +
|
|
347
|
+
"(id, sku, location_code, quantity, reason, actor, notes, " +
|
|
348
|
+
" cost_impact_minor, currency, status, occurred_at) " +
|
|
349
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'recorded', ?10)",
|
|
350
|
+
[id, sku, locCode, quantity, reason, actor, notes,
|
|
351
|
+
costImpactMinor, currency, occurredAt],
|
|
352
|
+
);
|
|
353
|
+
} catch (e3) {
|
|
354
|
+
// The header row failed to land. Restore the cost-layer
|
|
355
|
+
// consumption AND the shelf debit so the system returns to
|
|
356
|
+
// its pre-call state.
|
|
357
|
+
if (costLayers !== null) {
|
|
358
|
+
try {
|
|
359
|
+
await costLayers.recordReversal({
|
|
360
|
+
order_id: "writeoff:" + id,
|
|
361
|
+
line_id: "1",
|
|
362
|
+
reason: "writeoff:rollback:" + id,
|
|
363
|
+
});
|
|
364
|
+
} catch (_e4) { /* drop-silent — the original DB error is the operator's signal */ }
|
|
365
|
+
}
|
|
366
|
+
if (locCode !== null) {
|
|
367
|
+
try {
|
|
368
|
+
await locations.adjustStock({
|
|
369
|
+
sku: sku,
|
|
370
|
+
location_code: locCode,
|
|
371
|
+
delta: quantity,
|
|
372
|
+
reason: "writeoff:rollback:" + id,
|
|
373
|
+
});
|
|
374
|
+
} catch (_e5) { /* drop-silent — the original DB error is the operator's signal */ }
|
|
375
|
+
}
|
|
376
|
+
throw e3;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return await _getWriteoffRow(id);
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
// Hydrated row or null on miss.
|
|
383
|
+
getWriteoff: async function (writeoffId) {
|
|
384
|
+
var id = _id(writeoffId, "id");
|
|
385
|
+
return await _getWriteoffRow(id);
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
// Paginated read with optional filters. Ordered by
|
|
389
|
+
// (occurred_at DESC, id DESC) — the most recent write-offs at
|
|
390
|
+
// the top, deterministic tie-break on id for cursor stability.
|
|
391
|
+
listWriteoffs: async function (listOpts) {
|
|
392
|
+
listOpts = listOpts || {};
|
|
393
|
+
var from = _epochMs(listOpts.from, "from");
|
|
394
|
+
var to = _epochMs(listOpts.to, "to");
|
|
395
|
+
var reasonF = null;
|
|
396
|
+
if (listOpts.reason != null) reasonF = _reason(listOpts.reason);
|
|
397
|
+
var locF = null;
|
|
398
|
+
if (listOpts.location_code != null) locF = _code(listOpts.location_code, "location_code");
|
|
399
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
400
|
+
_limit(limit);
|
|
401
|
+
|
|
402
|
+
var cursorVals = null;
|
|
403
|
+
if (listOpts.cursor != null) {
|
|
404
|
+
if (typeof listOpts.cursor !== "string") {
|
|
405
|
+
throw new TypeError("inventory-writeoffs.listWriteoffs: cursor must be an opaque string or null");
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
409
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(WRITEOFF_ORDER_KEY)) {
|
|
410
|
+
throw new TypeError("inventory-writeoffs.listWriteoffs: cursor orderKey mismatch");
|
|
411
|
+
}
|
|
412
|
+
cursorVals = state.vals;
|
|
413
|
+
} catch (e) {
|
|
414
|
+
if (e instanceof TypeError) throw e;
|
|
415
|
+
throw new TypeError("inventory-writeoffs.listWriteoffs: cursor — " +
|
|
416
|
+
(e && e.message || "malformed"));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Build the WHERE clause incrementally so omitted filters land
|
|
421
|
+
// no parameter at all (clean explain plan vs. always-passing
|
|
422
|
+
// `?1 IS NULL OR col = ?1` shape).
|
|
423
|
+
var clauses = [];
|
|
424
|
+
var params = [];
|
|
425
|
+
var idx = 1;
|
|
426
|
+
if (from != null) { clauses.push("occurred_at >= ?" + idx); params.push(from); idx += 1; }
|
|
427
|
+
if (to != null) { clauses.push("occurred_at < ?" + idx); params.push(to); idx += 1; }
|
|
428
|
+
if (reasonF != null) { clauses.push("reason = ?" + idx); params.push(reasonF); idx += 1; }
|
|
429
|
+
if (locF != null) { clauses.push("location_code = ?" + idx); params.push(locF); idx += 1; }
|
|
430
|
+
if (cursorVals) {
|
|
431
|
+
clauses.push("(occurred_at < ?" + idx + " OR (occurred_at = ?" + idx + " AND id < ?" + (idx + 1) + "))");
|
|
432
|
+
params.push(cursorVals[0]); idx += 1;
|
|
433
|
+
params.push(cursorVals[1]); idx += 1;
|
|
434
|
+
}
|
|
435
|
+
var where = clauses.length ? "WHERE " + clauses.join(" AND ") + " " : "";
|
|
436
|
+
params.push(limit);
|
|
437
|
+
var sql = "SELECT * FROM inventory_writeoffs " + where +
|
|
438
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?" + idx;
|
|
439
|
+
var rows = (await query(sql, params)).rows;
|
|
440
|
+
var last = rows[rows.length - 1];
|
|
441
|
+
var next = null;
|
|
442
|
+
if (last && rows.length === limit) {
|
|
443
|
+
next = _b().pagination.encodeCursor({
|
|
444
|
+
orderKey: WRITEOFF_ORDER_KEY,
|
|
445
|
+
vals: [last.occurred_at, last.id],
|
|
446
|
+
forward: true,
|
|
447
|
+
}, cursorSecret);
|
|
448
|
+
}
|
|
449
|
+
return { rows: rows, next_cursor: next };
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
// Sum cost_impact_minor over the period, optionally narrowed by
|
|
453
|
+
// reason. Reversed rows are excluded (the operator un-did them;
|
|
454
|
+
// they didn't actually cost anything). Mixed-currency rows are
|
|
455
|
+
// refused — the operator reconciles before pulling a meaningful
|
|
456
|
+
// total. Returns per-reason + grand-total.
|
|
457
|
+
costImpactForPeriod: async function (input) {
|
|
458
|
+
if (!input || typeof input !== "object") {
|
|
459
|
+
throw new TypeError("inventory-writeoffs.costImpactForPeriod: input object required");
|
|
460
|
+
}
|
|
461
|
+
var from = _epochMs(input.from, "from");
|
|
462
|
+
var to = _epochMs(input.to, "to");
|
|
463
|
+
if (from == null || to == null) {
|
|
464
|
+
throw new TypeError("inventory-writeoffs.costImpactForPeriod: from and to are required (epoch-ms)");
|
|
465
|
+
}
|
|
466
|
+
if (to <= from) {
|
|
467
|
+
throw new TypeError("inventory-writeoffs.costImpactForPeriod: to must be > from");
|
|
468
|
+
}
|
|
469
|
+
var reasonF = null;
|
|
470
|
+
if (input.reason != null) reasonF = _reason(input.reason);
|
|
471
|
+
|
|
472
|
+
var clauses = ["occurred_at >= ?1", "occurred_at < ?2",
|
|
473
|
+
"status = 'recorded'", "cost_impact_minor IS NOT NULL"];
|
|
474
|
+
var params = [from, to];
|
|
475
|
+
if (reasonF != null) {
|
|
476
|
+
clauses.push("reason = ?3");
|
|
477
|
+
params.push(reasonF);
|
|
478
|
+
}
|
|
479
|
+
var sql = "SELECT reason, currency, SUM(cost_impact_minor) AS total " +
|
|
480
|
+
"FROM inventory_writeoffs WHERE " + clauses.join(" AND ") +
|
|
481
|
+
" GROUP BY reason, currency ORDER BY reason ASC";
|
|
482
|
+
var rows = (await query(sql, params)).rows;
|
|
483
|
+
|
|
484
|
+
// Currency-coherence gate: every grouped row in the period must
|
|
485
|
+
// share the same currency, or the grand-total is meaningless.
|
|
486
|
+
// When the operator hasn't recorded any cost-impact rows yet
|
|
487
|
+
// (no costLayers wiring, or all writeoffs had no on-hand
|
|
488
|
+
// layers), the rowset is empty and the answer is a zero-total.
|
|
489
|
+
var currency = null;
|
|
490
|
+
var byReason = [];
|
|
491
|
+
var grandTotal = 0;
|
|
492
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
493
|
+
if (currency == null) currency = rows[i].currency;
|
|
494
|
+
else if (rows[i].currency !== currency) {
|
|
495
|
+
throw new TypeError("inventory-writeoffs.costImpactForPeriod: rows in period " +
|
|
496
|
+
"span multiple currencies (" + currency + ", " + rows[i].currency +
|
|
497
|
+
") — reconcile before reporting");
|
|
498
|
+
}
|
|
499
|
+
// SQLite SUM() returns the integer as a JS number when in
|
|
500
|
+
// range; coerce defensively in case the underlying driver
|
|
501
|
+
// hands back a BigInt for very large totals.
|
|
502
|
+
var t = typeof rows[i].total === "bigint" ? Number(rows[i].total) : rows[i].total;
|
|
503
|
+
byReason.push({ reason: rows[i].reason, total_cogs_minor: t });
|
|
504
|
+
grandTotal += t;
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
from: from,
|
|
508
|
+
to: to,
|
|
509
|
+
by_reason: byReason,
|
|
510
|
+
total_cost_impact_minor: grandTotal,
|
|
511
|
+
currency: currency,
|
|
512
|
+
};
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// Restore a write-off. Adds stock back via
|
|
516
|
+
// inventoryLocations.adjustStock(+qty); when the original write-
|
|
517
|
+
// off had a cost-impact attribution, composes
|
|
518
|
+
// costLayers.recordReversal with the same synthetic order_id /
|
|
519
|
+
// line_id pair so the cost-layer pool gets the unit back at its
|
|
520
|
+
// original per-unit cost. Marks the row reversed + stamps
|
|
521
|
+
// reversed_at / reverse_reason. Refuses on already-reversed
|
|
522
|
+
// rows (no double-reverse).
|
|
523
|
+
reverseWriteoff: async function (input) {
|
|
524
|
+
if (!input || typeof input !== "object") {
|
|
525
|
+
throw new TypeError("inventory-writeoffs.reverseWriteoff: input object required");
|
|
526
|
+
}
|
|
527
|
+
var id = _id(input.id, "id");
|
|
528
|
+
var reason = _reverseReason(input.reason);
|
|
529
|
+
var row = await _getWriteoffRow(id);
|
|
530
|
+
if (!row) {
|
|
531
|
+
throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id + " not found");
|
|
532
|
+
}
|
|
533
|
+
if (row.status !== "recorded") {
|
|
534
|
+
throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
|
|
535
|
+
" is " + row.status + ", only recorded writeoffs can be reversed");
|
|
536
|
+
}
|
|
537
|
+
// Restore the shelf. Mirrors the recordWriteoff logic: when
|
|
538
|
+
// location_code is null the original write-off didn't touch a
|
|
539
|
+
// specific shelf, so nothing to restore.
|
|
540
|
+
if (row.location_code != null) {
|
|
541
|
+
await locations.adjustStock({
|
|
542
|
+
sku: row.sku,
|
|
543
|
+
location_code: row.location_code,
|
|
544
|
+
delta: row.quantity,
|
|
545
|
+
reason: "writeoff:reverse:" + id,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
// Restore the cost layer pool when the original write-off
|
|
549
|
+
// attributed COGS. costLayers may have been unwired since the
|
|
550
|
+
// original write-off — the operator gets a clear refusal in
|
|
551
|
+
// that case rather than a silent skip that desynchronizes the
|
|
552
|
+
// shelf from the cost ledger.
|
|
553
|
+
if (row.cost_impact_minor != null) {
|
|
554
|
+
if (costLayers === null) {
|
|
555
|
+
// Compensating action: undo the shelf restore so the
|
|
556
|
+
// operator sees a clean refusal rather than a half-applied
|
|
557
|
+
// reversal.
|
|
558
|
+
if (row.location_code != null) {
|
|
559
|
+
try {
|
|
560
|
+
await locations.adjustStock({
|
|
561
|
+
sku: row.sku,
|
|
562
|
+
location_code: row.location_code,
|
|
563
|
+
delta: -row.quantity,
|
|
564
|
+
reason: "writeoff:reverse:rollback:" + id,
|
|
565
|
+
});
|
|
566
|
+
} catch (_e1) { /* drop-silent — original refusal is the operator's signal */ }
|
|
567
|
+
}
|
|
568
|
+
throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
|
|
569
|
+
" carries a cost-impact attribution but costLayers is not wired — " +
|
|
570
|
+
"rewire costLayers before reversing this row");
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
await costLayers.recordReversal({
|
|
574
|
+
order_id: "writeoff:" + id,
|
|
575
|
+
line_id: "1",
|
|
576
|
+
reason: reason,
|
|
577
|
+
});
|
|
578
|
+
} catch (e) {
|
|
579
|
+
if (row.location_code != null) {
|
|
580
|
+
try {
|
|
581
|
+
await locations.adjustStock({
|
|
582
|
+
sku: row.sku,
|
|
583
|
+
location_code: row.location_code,
|
|
584
|
+
delta: -row.quantity,
|
|
585
|
+
reason: "writeoff:reverse:rollback:" + id,
|
|
586
|
+
});
|
|
587
|
+
} catch (_e2) { /* drop-silent — the costLayers error is the operator's signal */ }
|
|
588
|
+
}
|
|
589
|
+
throw e;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
var ts = _now();
|
|
593
|
+
try {
|
|
594
|
+
await query(
|
|
595
|
+
"UPDATE inventory_writeoffs SET status = 'reversed', reversed_at = ?1, " +
|
|
596
|
+
"reverse_reason = ?2 WHERE id = ?3",
|
|
597
|
+
[ts, reason, id],
|
|
598
|
+
);
|
|
599
|
+
} catch (e3) {
|
|
600
|
+
if (row.cost_impact_minor != null && costLayers !== null) {
|
|
601
|
+
// Best-effort compensating: re-consume the cost layer so
|
|
602
|
+
// the cost ledger doesn't sit in a contradictory state.
|
|
603
|
+
// Failure here is drop-silent because the original DB
|
|
604
|
+
// error is what the operator needs to fix; the audit row
|
|
605
|
+
// on cost_layers still tells the story.
|
|
606
|
+
try {
|
|
607
|
+
await costLayers.consumeForSale({
|
|
608
|
+
sku: row.sku,
|
|
609
|
+
quantity: row.quantity,
|
|
610
|
+
order_id: "writeoff:" + id,
|
|
611
|
+
line_id: "1",
|
|
612
|
+
});
|
|
613
|
+
} catch (_e4) { /* drop-silent — original DB error is the operator's signal */ }
|
|
614
|
+
}
|
|
615
|
+
if (row.location_code != null) {
|
|
616
|
+
try {
|
|
617
|
+
await locations.adjustStock({
|
|
618
|
+
sku: row.sku,
|
|
619
|
+
location_code: row.location_code,
|
|
620
|
+
delta: -row.quantity,
|
|
621
|
+
reason: "writeoff:reverse:rollback:" + id,
|
|
622
|
+
});
|
|
623
|
+
} catch (_e5) { /* drop-silent — original DB error is the operator's signal */ }
|
|
624
|
+
}
|
|
625
|
+
throw e3;
|
|
626
|
+
}
|
|
627
|
+
return await _getWriteoffRow(id);
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
module.exports = {
|
|
633
|
+
create: create,
|
|
634
|
+
WRITEOFF_REASONS: WRITEOFF_REASONS,
|
|
635
|
+
WRITEOFF_STATUSES: WRITEOFF_STATUSES,
|
|
636
|
+
};
|