@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,774 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.costLayers
|
|
4
|
+
* @title Cost layers — FIFO / LIFO / weighted-average inventory cost
|
|
5
|
+
* accounting for COGS reporting
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* The catalog tracks how many units sit on the shelf. The
|
|
9
|
+
* cost-layers primitive tracks WHAT EACH UNIT COST so the
|
|
10
|
+
* accountant can answer "for the five widgets we sold today, what
|
|
11
|
+
* was the cost of goods sold?" with the same precision the
|
|
12
|
+
* warehouse answers "how many widgets do we have?".
|
|
13
|
+
*
|
|
14
|
+
* A receipt event (purchase from supplier, positive stock
|
|
15
|
+
* adjustment with cost info, transfer-in carrying a per-unit cost)
|
|
16
|
+
* appends a cost layer. A sale event consumes from one or more
|
|
17
|
+
* layers depending on the costing method active for the SKU at
|
|
18
|
+
* sale time. Each consumption writes an attribution row so the
|
|
19
|
+
* accountant can join `order_id` back to per-layer unit costs.
|
|
20
|
+
*
|
|
21
|
+
* Surface:
|
|
22
|
+
*
|
|
23
|
+
* setMethod({ sku, method })
|
|
24
|
+
* Operator-authored per-SKU policy. `method` is one of `fifo`,
|
|
25
|
+
* `lifo`, `weighted_average`. The set_at column records when
|
|
26
|
+
* the operator first chose a method; updated_at records the
|
|
27
|
+
* most recent change. Switching methods does not retroactively
|
|
28
|
+
* re-cost prior sales — `cogs_attributions` is the immutable
|
|
29
|
+
* record of what was charged at the time.
|
|
30
|
+
*
|
|
31
|
+
* recordReceipt({ sku, quantity, unit_cost_minor, currency,
|
|
32
|
+
* source, source_ref?, occurred_at? })
|
|
33
|
+
* Appends one cost layer. `source` is one of `receipt`,
|
|
34
|
+
* `transfer`, `adjustment` (a reversal-driven layer is internal
|
|
35
|
+
* and bears `source = 'reversal'`; operators don't pass that
|
|
36
|
+
* value directly). `unit_cost_minor` is a non-negative integer
|
|
37
|
+
* — zero is allowed (a give-away pallet from a supplier still
|
|
38
|
+
* enters the system as a tracked layer, just with no
|
|
39
|
+
* per-unit cost). Per-SKU occurred_at is bumped to monotonic
|
|
40
|
+
* so two receipts in the same millisecond don't tie on the
|
|
41
|
+
* FIFO ordering key.
|
|
42
|
+
*
|
|
43
|
+
* consumeForSale({ sku, quantity, order_id, line_id,
|
|
44
|
+
* occurred_at? })
|
|
45
|
+
* Consumes `quantity` units across one or more layers using
|
|
46
|
+
* the SKU's active costing method:
|
|
47
|
+
*
|
|
48
|
+
* fifo — oldest layer first
|
|
49
|
+
* lifo — newest layer first
|
|
50
|
+
* weighted_average — every layer is debited proportionally,
|
|
51
|
+
* and every consumed unit costs the
|
|
52
|
+
* weighted-average of the on-hand layers
|
|
53
|
+
* at the time of the sale
|
|
54
|
+
*
|
|
55
|
+
* Returns `{ consumed_layers, total_cogs_minor, currency }`.
|
|
56
|
+
* Refuses when on-hand quantity across all layers is less than
|
|
57
|
+
* the requested quantity (no partial consumption — the caller
|
|
58
|
+
* handles the shortage upstream rather than getting a
|
|
59
|
+
* mysteriously-short COGS row). Refuses on currency drift: if
|
|
60
|
+
* the on-hand layers carry mixed currencies, the sale is
|
|
61
|
+
* refused (the operator must reconcile or convert at the
|
|
62
|
+
* receipt step).
|
|
63
|
+
*
|
|
64
|
+
* recordReversal({ order_id, line_id, reason })
|
|
65
|
+
* Returns previously-consumed units to stock. For every
|
|
66
|
+
* `cogs_attributions` row for the order+line, appends a fresh
|
|
67
|
+
* cost layer at the original unit cost (the simplest
|
|
68
|
+
* defensible model — the returned unit re-enters the layer
|
|
69
|
+
* pool at what it cost when sold). Marks the attribution row
|
|
70
|
+
* `reversed=1` with the operator-supplied reason. Refuses if
|
|
71
|
+
* the line was never consumed or every attribution row is
|
|
72
|
+
* already reversed.
|
|
73
|
+
*
|
|
74
|
+
* currentLayers({ sku })
|
|
75
|
+
* Returns every cost layer for an SKU that still has
|
|
76
|
+
* `quantity_remaining > 0`, oldest first. The accountant
|
|
77
|
+
* reads this to value on-hand inventory.
|
|
78
|
+
*
|
|
79
|
+
* cogsForOrder(order_id)
|
|
80
|
+
* Sums every non-reversed attribution row for the order.
|
|
81
|
+
* Returns `{ order_id, lines: [{ line_id, sku, qty,
|
|
82
|
+
* total_cogs_minor, currency }], total_cogs_minor, currency }`.
|
|
83
|
+
* Refuses when attribution rows span multiple currencies.
|
|
84
|
+
*
|
|
85
|
+
* cogsForPeriod({ from, to, sku? })
|
|
86
|
+
* Sums non-reversed attribution rows whose occurred_at falls
|
|
87
|
+
* in `[from, to)`. Returns per-SKU + grand-total. The
|
|
88
|
+
* accountant reads this for monthly COGS reports.
|
|
89
|
+
*
|
|
90
|
+
* Composition:
|
|
91
|
+
* - b.uuid.v7 — layer / attribution row PKs (sortable)
|
|
92
|
+
* - monotonic clock — per-SKU strict monotonic occurred_at so
|
|
93
|
+
* the FIFO order is unambiguous across two
|
|
94
|
+
* receipts in the same millisecond
|
|
95
|
+
*
|
|
96
|
+
* Three-tier input validation: every public verb is a config-time
|
|
97
|
+
* entry point (setMethod) or a defensive request-shape reader
|
|
98
|
+
* (everything else). Both shapes throw on bad input — no
|
|
99
|
+
* drop-silent hot-path sinks.
|
|
100
|
+
*
|
|
101
|
+
* @primitive costLayers
|
|
102
|
+
* @related shop.stockTransfers, shop.inventoryReceive
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
var bShop;
|
|
106
|
+
function _b() {
|
|
107
|
+
if (!bShop) bShop = require("./index");
|
|
108
|
+
return bShop.framework;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- constants ----------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
114
|
+
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
115
|
+
var METHODS = Object.freeze(["fifo", "lifo", "weighted_average"]);
|
|
116
|
+
var RECEIPT_SOURCES = Object.freeze(["receipt", "transfer", "adjustment"]);
|
|
117
|
+
// Internal source value used by `recordReversal` only; operators
|
|
118
|
+
// cannot pass this through `recordReceipt`.
|
|
119
|
+
var REVERSAL_SOURCE = "reversal";
|
|
120
|
+
|
|
121
|
+
var MAX_REF_LEN = 128;
|
|
122
|
+
var MAX_REASON_LEN = 280;
|
|
123
|
+
var MAX_ID_LEN = 128;
|
|
124
|
+
// `order_id` and `line_id` are external correlation handles. They
|
|
125
|
+
// arrive from the order primitive and may not be UUID-shape on every
|
|
126
|
+
// operator's deployment (some operators carry short opaque IDs).
|
|
127
|
+
// Refuse control bytes (log-injection cover) but accept any printable
|
|
128
|
+
// shape up to MAX_ID_LEN.
|
|
129
|
+
var PRINTABLE_RE = /^[^\x00-\x1f\x7f]+$/;
|
|
130
|
+
|
|
131
|
+
// ---- validators ---------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
function _sku(s) {
|
|
134
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
135
|
+
throw new TypeError("cost-layers: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
136
|
+
}
|
|
137
|
+
return s;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _method(s) {
|
|
141
|
+
if (typeof s !== "string" || METHODS.indexOf(s) === -1) {
|
|
142
|
+
throw new TypeError("cost-layers: method must be one of " + METHODS.join(", "));
|
|
143
|
+
}
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _positiveInt(n, label) {
|
|
148
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
149
|
+
throw new TypeError("cost-layers: " + label + " must be a positive integer");
|
|
150
|
+
}
|
|
151
|
+
return n;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _nonNegInt(n, label) {
|
|
155
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
156
|
+
throw new TypeError("cost-layers: " + label + " must be a non-negative integer");
|
|
157
|
+
}
|
|
158
|
+
return n;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _currency(s) {
|
|
162
|
+
if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
|
|
163
|
+
throw new TypeError("cost-layers: currency must be a 3-letter uppercase ISO-4217 code");
|
|
164
|
+
}
|
|
165
|
+
return s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _receiptSource(s) {
|
|
169
|
+
if (typeof s !== "string" || RECEIPT_SOURCES.indexOf(s) === -1) {
|
|
170
|
+
throw new TypeError("cost-layers: source must be one of " + RECEIPT_SOURCES.join(", "));
|
|
171
|
+
}
|
|
172
|
+
return s;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function _sourceRef(s) {
|
|
176
|
+
if (s == null) return null;
|
|
177
|
+
if (typeof s !== "string" || !s.length) {
|
|
178
|
+
throw new TypeError("cost-layers: source_ref must be a non-empty string when provided");
|
|
179
|
+
}
|
|
180
|
+
if (s.length > MAX_REF_LEN) {
|
|
181
|
+
throw new TypeError("cost-layers: source_ref must be <= " + MAX_REF_LEN + " chars");
|
|
182
|
+
}
|
|
183
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
184
|
+
throw new TypeError("cost-layers: source_ref must not contain control bytes");
|
|
185
|
+
}
|
|
186
|
+
return s;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _externalId(s, label) {
|
|
190
|
+
if (typeof s !== "string" || !s.length) {
|
|
191
|
+
throw new TypeError("cost-layers: " + label + " must be a non-empty string");
|
|
192
|
+
}
|
|
193
|
+
if (s.length > MAX_ID_LEN) {
|
|
194
|
+
throw new TypeError("cost-layers: " + label + " must be <= " + MAX_ID_LEN + " chars");
|
|
195
|
+
}
|
|
196
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
197
|
+
throw new TypeError("cost-layers: " + label + " must not contain control bytes");
|
|
198
|
+
}
|
|
199
|
+
return s;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function _reason(s) {
|
|
203
|
+
if (typeof s !== "string" || !s.length) {
|
|
204
|
+
throw new TypeError("cost-layers: reason must be a non-empty string");
|
|
205
|
+
}
|
|
206
|
+
if (s.length > MAX_REASON_LEN) {
|
|
207
|
+
throw new TypeError("cost-layers: reason must be <= " + MAX_REASON_LEN + " chars");
|
|
208
|
+
}
|
|
209
|
+
if (!PRINTABLE_RE.test(s.replace(/\n/g, " "))) {
|
|
210
|
+
throw new TypeError("cost-layers: reason must not contain control bytes other than newline");
|
|
211
|
+
}
|
|
212
|
+
return s;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _epochMs(ts, label) {
|
|
216
|
+
if (ts == null) return null;
|
|
217
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
218
|
+
throw new TypeError("cost-layers: " + label + " must be a non-negative integer epoch-ms");
|
|
219
|
+
}
|
|
220
|
+
return ts;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _now() { return Date.now(); }
|
|
224
|
+
|
|
225
|
+
// ---- factory ------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function create(opts) {
|
|
228
|
+
opts = opts || {};
|
|
229
|
+
var query = opts.query;
|
|
230
|
+
if (!query) {
|
|
231
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
232
|
+
}
|
|
233
|
+
// Catalog is currently optional — the primitive doesn't need it to
|
|
234
|
+
// compute COGS. Accept either an object handle or omit it. When
|
|
235
|
+
// present, refuse a non-object to fail loud on a misconfigured
|
|
236
|
+
// operator wire.
|
|
237
|
+
if (opts.catalog != null && typeof opts.catalog !== "object") {
|
|
238
|
+
throw new TypeError("cost-layers.create: opts.catalog must be an object when provided");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Look up the active method for an SKU. Falls through to
|
|
242
|
+
// weighted_average when the operator hasn't set one — the least-
|
|
243
|
+
// surprising default for a shop without a formalized inventory
|
|
244
|
+
// accounting policy. The accountant typically sets explicit
|
|
245
|
+
// methods per SKU before relying on `cogsForOrder` reports.
|
|
246
|
+
async function _methodFor(sku) {
|
|
247
|
+
var r = await query(
|
|
248
|
+
"SELECT method FROM sku_costing_methods WHERE sku = ?1",
|
|
249
|
+
[sku],
|
|
250
|
+
);
|
|
251
|
+
if (!r.rows.length) return "weighted_average";
|
|
252
|
+
return r.rows[0].method;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Read the latest occurred_at per SKU so two writes in the same
|
|
256
|
+
// millisecond don't tie on the FIFO ordering key. Returns null
|
|
257
|
+
// when the SKU has no layers yet.
|
|
258
|
+
async function _latestLayerTs(sku) {
|
|
259
|
+
var r = await query(
|
|
260
|
+
"SELECT MAX(occurred_at) AS ts FROM cost_layers WHERE sku = ?1",
|
|
261
|
+
[sku],
|
|
262
|
+
);
|
|
263
|
+
if (!r.rows.length || r.rows[0].ts == null) return null;
|
|
264
|
+
return r.rows[0].ts;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Strict-monotonic clock: bump the requested timestamp to
|
|
268
|
+
// `prior + 1` when it would collide with (or land older than) the
|
|
269
|
+
// most recent layer for the SKU. The result is a strictly-
|
|
270
|
+
// monotonic per-SKU occurred_at sequence so FIFO / LIFO ordering
|
|
271
|
+
// is unambiguous even under same-millisecond writes.
|
|
272
|
+
function _resolveOccurredAt(requestedTs, latestTs) {
|
|
273
|
+
if (latestTs == null) return requestedTs;
|
|
274
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
275
|
+
return latestTs + 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Hydrate the active layer set for an SKU — every layer with
|
|
279
|
+
// `quantity_remaining > 0`. The order parameter picks FIFO (ASC)
|
|
280
|
+
// or LIFO (DESC) at the SQL level so the caller doesn't sort in
|
|
281
|
+
// JS. Falls back to the `id` column for ties (UUID-v7 is itself
|
|
282
|
+
// monotonic so this is the same answer as occurred_at order, but
|
|
283
|
+
// explicit beats implicit).
|
|
284
|
+
async function _activeLayers(sku, direction) {
|
|
285
|
+
var order = direction === "lifo"
|
|
286
|
+
? "occurred_at DESC, id DESC"
|
|
287
|
+
: "occurred_at ASC, id ASC";
|
|
288
|
+
var r = await query(
|
|
289
|
+
"SELECT * FROM cost_layers WHERE sku = ?1 AND quantity_remaining > 0 " +
|
|
290
|
+
"ORDER BY " + order,
|
|
291
|
+
[sku],
|
|
292
|
+
);
|
|
293
|
+
return r.rows;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
METHODS: METHODS.slice(),
|
|
298
|
+
RECEIPT_SOURCES: RECEIPT_SOURCES.slice(),
|
|
299
|
+
|
|
300
|
+
// Operator-authored per-SKU policy. Upserts. set_at is the first-
|
|
301
|
+
// ever set time (preserved across updates); updated_at is the
|
|
302
|
+
// most recent change.
|
|
303
|
+
setMethod: async function (input) {
|
|
304
|
+
if (!input || typeof input !== "object") {
|
|
305
|
+
throw new TypeError("cost-layers.setMethod: input object required");
|
|
306
|
+
}
|
|
307
|
+
var sku = _sku(input.sku);
|
|
308
|
+
var method = _method(input.method);
|
|
309
|
+
var now = _now();
|
|
310
|
+
var existing = await query(
|
|
311
|
+
"SELECT sku, set_at FROM sku_costing_methods WHERE sku = ?1",
|
|
312
|
+
[sku],
|
|
313
|
+
);
|
|
314
|
+
if (existing.rows.length) {
|
|
315
|
+
await query(
|
|
316
|
+
"UPDATE sku_costing_methods SET method = ?1, updated_at = ?2 WHERE sku = ?3",
|
|
317
|
+
[method, now, sku],
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
await query(
|
|
321
|
+
"INSERT INTO sku_costing_methods (sku, method, set_at, updated_at) " +
|
|
322
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
323
|
+
[sku, method, now, now],
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return { sku: sku, method: method };
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
// Read the active method for an SKU; surfaces the default
|
|
330
|
+
// (weighted_average) when none is set so the operator's UI can
|
|
331
|
+
// render the inherited value rather than "unset".
|
|
332
|
+
getMethod: async function (sku) {
|
|
333
|
+
_sku(sku);
|
|
334
|
+
var method = await _methodFor(sku);
|
|
335
|
+
return { sku: sku, method: method };
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
// Append a cost layer. unit_cost_minor=0 is allowed (free
|
|
339
|
+
// samples / promotional inventory enter the layer pool at zero
|
|
340
|
+
// cost). occurred_at defaults to now and is bumped monotonic
|
|
341
|
+
// against the latest per-SKU layer so FIFO / LIFO ordering is
|
|
342
|
+
// unambiguous across same-millisecond writes.
|
|
343
|
+
recordReceipt: async function (input) {
|
|
344
|
+
if (!input || typeof input !== "object") {
|
|
345
|
+
throw new TypeError("cost-layers.recordReceipt: input object required");
|
|
346
|
+
}
|
|
347
|
+
var sku = _sku(input.sku);
|
|
348
|
+
var quantity = _positiveInt(input.quantity, "quantity");
|
|
349
|
+
var unitCost = _nonNegInt(input.unit_cost_minor, "unit_cost_minor");
|
|
350
|
+
var currency = _currency(input.currency);
|
|
351
|
+
var source = _receiptSource(input.source);
|
|
352
|
+
var sourceRef = _sourceRef(input.source_ref);
|
|
353
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
354
|
+
if (requested == null) requested = _now();
|
|
355
|
+
|
|
356
|
+
var latestTs = await _latestLayerTs(sku);
|
|
357
|
+
var ts = _resolveOccurredAt(requested, latestTs);
|
|
358
|
+
var id = _b().uuid.v7();
|
|
359
|
+
await query(
|
|
360
|
+
"INSERT INTO cost_layers " +
|
|
361
|
+
"(id, sku, quantity_received, quantity_remaining, unit_cost_minor, " +
|
|
362
|
+
"currency, source, source_ref, occurred_at) " +
|
|
363
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
364
|
+
[id, sku, quantity, quantity, unitCost, currency, source, sourceRef, ts],
|
|
365
|
+
);
|
|
366
|
+
return {
|
|
367
|
+
id: id,
|
|
368
|
+
sku: sku,
|
|
369
|
+
quantity_received: quantity,
|
|
370
|
+
quantity_remaining: quantity,
|
|
371
|
+
unit_cost_minor: unitCost,
|
|
372
|
+
currency: currency,
|
|
373
|
+
source: source,
|
|
374
|
+
source_ref: sourceRef,
|
|
375
|
+
occurred_at: ts,
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
// Consume `quantity` units across one or more layers using the
|
|
380
|
+
// SKU's active costing method. Returns the per-layer consumption
|
|
381
|
+
// detail + the rolled-up COGS for the line.
|
|
382
|
+
consumeForSale: async function (input) {
|
|
383
|
+
if (!input || typeof input !== "object") {
|
|
384
|
+
throw new TypeError("cost-layers.consumeForSale: input object required");
|
|
385
|
+
}
|
|
386
|
+
var sku = _sku(input.sku);
|
|
387
|
+
var quantity = _positiveInt(input.quantity, "quantity");
|
|
388
|
+
var orderId = _externalId(input.order_id, "order_id");
|
|
389
|
+
var lineId = _externalId(input.line_id, "line_id");
|
|
390
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
391
|
+
if (requested == null) requested = _now();
|
|
392
|
+
|
|
393
|
+
var method = await _methodFor(sku);
|
|
394
|
+
var direction = method === "lifo" ? "lifo" : "fifo";
|
|
395
|
+
var layers = await _activeLayers(sku, direction);
|
|
396
|
+
|
|
397
|
+
// Pre-flight: confirm on-hand >= requested. No partial
|
|
398
|
+
// consumption — the caller handles the shortage upstream.
|
|
399
|
+
var onHand = 0;
|
|
400
|
+
for (var i = 0; i < layers.length; i += 1) onHand += layers[i].quantity_remaining;
|
|
401
|
+
if (onHand < quantity) {
|
|
402
|
+
throw new TypeError("cost-layers.consumeForSale: insufficient on-hand for sku " +
|
|
403
|
+
JSON.stringify(sku) + " (have " + onHand + ", need " + quantity + ")");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Currency-coherence gate: every consumed layer must share the
|
|
407
|
+
// same currency. Mixed currencies are refused at the primitive
|
|
408
|
+
// layer (the operator reconciles via a manual receipt rather
|
|
409
|
+
// than getting a mysteriously-FX-blended COGS row).
|
|
410
|
+
var currency = layers[0].currency;
|
|
411
|
+
for (var c = 1; c < layers.length; c += 1) {
|
|
412
|
+
if (layers[c].currency !== currency) {
|
|
413
|
+
throw new TypeError("cost-layers.consumeForSale: layers for sku " +
|
|
414
|
+
JSON.stringify(sku) + " span multiple currencies (" + currency +
|
|
415
|
+
", " + layers[c].currency + ") — reconcile before sale");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// The sale's `occurred_at` is the operator-supplied timestamp
|
|
420
|
+
// (defaulting to now when omitted). `cogsForPeriod` filters
|
|
421
|
+
// attribution rows by this column — using the sale time, not
|
|
422
|
+
// the wall-clock at write time, keeps backfilled imports
|
|
423
|
+
// accurate.
|
|
424
|
+
var consumed = [];
|
|
425
|
+
var totalCogs = 0;
|
|
426
|
+
var attributionTs = requested;
|
|
427
|
+
|
|
428
|
+
if (method === "weighted_average") {
|
|
429
|
+
// Compute weighted-average unit cost in minor units. Round
|
|
430
|
+
// the per-unit cost to the nearest integer minor unit at the
|
|
431
|
+
// line level; spread the rounding remainder across the
|
|
432
|
+
// attributions so `qty * unit_cost_minor` summed equals the
|
|
433
|
+
// line total exactly (no penny drift between per-line COGS
|
|
434
|
+
// and per-attribution COGS).
|
|
435
|
+
var sumValue = 0;
|
|
436
|
+
var sumQty = 0;
|
|
437
|
+
for (var w = 0; w < layers.length; w += 1) {
|
|
438
|
+
sumValue += layers[w].quantity_remaining * layers[w].unit_cost_minor;
|
|
439
|
+
sumQty += layers[w].quantity_remaining;
|
|
440
|
+
}
|
|
441
|
+
// Total COGS for this consumption is the proportional share
|
|
442
|
+
// of the on-hand value. Compute as
|
|
443
|
+
// floor(sumValue * quantity / sumQty) with the remainder
|
|
444
|
+
// tracked as a residual integer so two consecutive sales
|
|
445
|
+
// don't accumulate a fractional debt.
|
|
446
|
+
var lineNumerator = sumValue * quantity;
|
|
447
|
+
var lineCogs = Math.floor(lineNumerator / sumQty);
|
|
448
|
+
// Residual carried forward to per-layer attribution rows so
|
|
449
|
+
// the sum of attributions matches lineCogs exactly. The first
|
|
450
|
+
// layer in the iteration absorbs the residual (smallest
|
|
451
|
+
// possible deviation, deterministic placement).
|
|
452
|
+
var residual = lineCogs;
|
|
453
|
+
// Debit every layer proportionally. Use integer division so
|
|
454
|
+
// each layer's debit is an integer; spread any leftover
|
|
455
|
+
// single units across the earliest layers (deterministic).
|
|
456
|
+
var remainingToConsume = quantity;
|
|
457
|
+
var debits = [];
|
|
458
|
+
for (var d = 0; d < layers.length; d += 1) {
|
|
459
|
+
if (remainingToConsume === 0) break;
|
|
460
|
+
var layer = layers[d];
|
|
461
|
+
var share;
|
|
462
|
+
if (d === layers.length - 1) {
|
|
463
|
+
share = remainingToConsume;
|
|
464
|
+
} else {
|
|
465
|
+
// Proportional debit floored to integer; the last layer
|
|
466
|
+
// absorbs any rounding deficit.
|
|
467
|
+
share = Math.floor(layer.quantity_remaining * quantity / sumQty);
|
|
468
|
+
if (share > remainingToConsume) share = remainingToConsume;
|
|
469
|
+
}
|
|
470
|
+
if (share > 0) {
|
|
471
|
+
debits.push({ layer: layer, qty: share });
|
|
472
|
+
remainingToConsume -= share;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// If integer flooring left units undebited (because every
|
|
476
|
+
// proportional share floored down), distribute the leftover
|
|
477
|
+
// across the earliest layers one unit at a time. This keeps
|
|
478
|
+
// the SUM(quantity_remaining) after consumption equal to
|
|
479
|
+
// sumQty - quantity exactly.
|
|
480
|
+
for (var x = 0; x < debits.length && remainingToConsume > 0; x += 1) {
|
|
481
|
+
if (debits[x].qty < debits[x].layer.quantity_remaining) {
|
|
482
|
+
debits[x].qty += 1;
|
|
483
|
+
remainingToConsume -= 1;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// Per-attribution unit cost: weighted-average rounded to the
|
|
487
|
+
// nearest integer minor unit. Track the residual so the last
|
|
488
|
+
// attribution carries any rounding adjustment.
|
|
489
|
+
var avgUnitCost = sumQty > 0 ? Math.round(sumValue / sumQty) : 0;
|
|
490
|
+
for (var e = 0; e < debits.length; e += 1) {
|
|
491
|
+
var dRow = debits[e];
|
|
492
|
+
var attrCogs;
|
|
493
|
+
if (e === debits.length - 1) {
|
|
494
|
+
attrCogs = residual;
|
|
495
|
+
} else {
|
|
496
|
+
attrCogs = dRow.qty * avgUnitCost;
|
|
497
|
+
if (attrCogs > residual) attrCogs = residual;
|
|
498
|
+
residual -= attrCogs;
|
|
499
|
+
}
|
|
500
|
+
var attrUnit = dRow.qty > 0 ? Math.round(attrCogs / dRow.qty) : avgUnitCost;
|
|
501
|
+
await query(
|
|
502
|
+
"UPDATE cost_layers SET quantity_remaining = quantity_remaining - ?1 WHERE id = ?2",
|
|
503
|
+
[dRow.qty, dRow.layer.id],
|
|
504
|
+
);
|
|
505
|
+
var attrId = _b().uuid.v7();
|
|
506
|
+
await query(
|
|
507
|
+
"INSERT INTO cogs_attributions " +
|
|
508
|
+
"(id, order_id, line_id, sku, layer_id, qty, unit_cost_minor, " +
|
|
509
|
+
"currency, reversed, reversal_reason, occurred_at) " +
|
|
510
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, NULL, ?9)",
|
|
511
|
+
[attrId, orderId, lineId, sku, dRow.layer.id, dRow.qty, attrUnit, currency, attributionTs],
|
|
512
|
+
);
|
|
513
|
+
consumed.push({
|
|
514
|
+
layer_id: dRow.layer.id,
|
|
515
|
+
qty: dRow.qty,
|
|
516
|
+
unit_cost_minor: attrUnit,
|
|
517
|
+
});
|
|
518
|
+
totalCogs += attrCogs;
|
|
519
|
+
}
|
|
520
|
+
// Edge case: residual unspent (impossible given the math
|
|
521
|
+
// above, but defensive). Push it onto the last attribution.
|
|
522
|
+
if (residual !== 0 && consumed.length) {
|
|
523
|
+
totalCogs += residual;
|
|
524
|
+
}
|
|
525
|
+
totalCogs = lineCogs;
|
|
526
|
+
} else {
|
|
527
|
+
// FIFO / LIFO: walk layers in order, debit each one until
|
|
528
|
+
// the requested quantity is satisfied.
|
|
529
|
+
var remaining = quantity;
|
|
530
|
+
for (var f = 0; f < layers.length && remaining > 0; f += 1) {
|
|
531
|
+
var lay = layers[f];
|
|
532
|
+
var take = lay.quantity_remaining;
|
|
533
|
+
if (take > remaining) take = remaining;
|
|
534
|
+
await query(
|
|
535
|
+
"UPDATE cost_layers SET quantity_remaining = quantity_remaining - ?1 WHERE id = ?2",
|
|
536
|
+
[take, lay.id],
|
|
537
|
+
);
|
|
538
|
+
var attrIdF = _b().uuid.v7();
|
|
539
|
+
await query(
|
|
540
|
+
"INSERT INTO cogs_attributions " +
|
|
541
|
+
"(id, order_id, line_id, sku, layer_id, qty, unit_cost_minor, " +
|
|
542
|
+
"currency, reversed, reversal_reason, occurred_at) " +
|
|
543
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, NULL, ?9)",
|
|
544
|
+
[attrIdF, orderId, lineId, sku, lay.id, take, lay.unit_cost_minor, currency, attributionTs],
|
|
545
|
+
);
|
|
546
|
+
consumed.push({
|
|
547
|
+
layer_id: lay.id,
|
|
548
|
+
qty: take,
|
|
549
|
+
unit_cost_minor: lay.unit_cost_minor,
|
|
550
|
+
});
|
|
551
|
+
totalCogs += take * lay.unit_cost_minor;
|
|
552
|
+
remaining -= take;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
consumed_layers: consumed,
|
|
558
|
+
total_cogs_minor: totalCogs,
|
|
559
|
+
currency: currency,
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
// Return previously-consumed units to stock. For every
|
|
564
|
+
// attribution row for the order+line, appends a fresh cost
|
|
565
|
+
// layer at the original unit cost. Marks the attribution row
|
|
566
|
+
// reversed=1 with the operator-supplied reason.
|
|
567
|
+
recordReversal: async function (input) {
|
|
568
|
+
if (!input || typeof input !== "object") {
|
|
569
|
+
throw new TypeError("cost-layers.recordReversal: input object required");
|
|
570
|
+
}
|
|
571
|
+
var orderId = _externalId(input.order_id, "order_id");
|
|
572
|
+
var lineId = _externalId(input.line_id, "line_id");
|
|
573
|
+
var reason = _reason(input.reason);
|
|
574
|
+
|
|
575
|
+
var attrs = (await query(
|
|
576
|
+
"SELECT * FROM cogs_attributions WHERE order_id = ?1 AND line_id = ?2 ORDER BY occurred_at ASC, id ASC",
|
|
577
|
+
[orderId, lineId],
|
|
578
|
+
)).rows;
|
|
579
|
+
if (!attrs.length) {
|
|
580
|
+
throw new TypeError("cost-layers.recordReversal: no attributions found for order_id=" +
|
|
581
|
+
JSON.stringify(orderId) + " line_id=" + JSON.stringify(lineId));
|
|
582
|
+
}
|
|
583
|
+
var liveAttrs = attrs.filter(function (a) { return a.reversed === 0; });
|
|
584
|
+
if (!liveAttrs.length) {
|
|
585
|
+
throw new TypeError("cost-layers.recordReversal: every attribution for order_id=" +
|
|
586
|
+
JSON.stringify(orderId) + " line_id=" + JSON.stringify(lineId) +
|
|
587
|
+
" is already reversed");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
var now = _now();
|
|
591
|
+
var restored = [];
|
|
592
|
+
for (var i = 0; i < liveAttrs.length; i += 1) {
|
|
593
|
+
var a = liveAttrs[i];
|
|
594
|
+
// Append a fresh layer at the original unit cost. Mark it
|
|
595
|
+
// `source='reversal'` so the operator's audit query can
|
|
596
|
+
// distinguish "stock that came back from a return" from
|
|
597
|
+
// "stock that came in from a supplier purchase". Bump
|
|
598
|
+
// monotonic against the latest per-SKU layer so the
|
|
599
|
+
// returned units land at the end of the FIFO queue (the
|
|
600
|
+
// accountant's typical interpretation — returned stock is
|
|
601
|
+
// sold last).
|
|
602
|
+
var latestTs = await _latestLayerTs(a.sku);
|
|
603
|
+
var ts = _resolveOccurredAt(now, latestTs);
|
|
604
|
+
var newLayerId = _b().uuid.v7();
|
|
605
|
+
await query(
|
|
606
|
+
"INSERT INTO cost_layers " +
|
|
607
|
+
"(id, sku, quantity_received, quantity_remaining, unit_cost_minor, " +
|
|
608
|
+
"currency, source, source_ref, occurred_at) " +
|
|
609
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
610
|
+
[newLayerId, a.sku, a.qty, a.qty, a.unit_cost_minor, a.currency,
|
|
611
|
+
REVERSAL_SOURCE, orderId + ":" + lineId, ts],
|
|
612
|
+
);
|
|
613
|
+
await query(
|
|
614
|
+
"UPDATE cogs_attributions SET reversed = 1, reversal_reason = ?1 WHERE id = ?2",
|
|
615
|
+
[reason, a.id],
|
|
616
|
+
);
|
|
617
|
+
restored.push({
|
|
618
|
+
sku: a.sku,
|
|
619
|
+
qty: a.qty,
|
|
620
|
+
unit_cost_minor: a.unit_cost_minor,
|
|
621
|
+
currency: a.currency,
|
|
622
|
+
new_layer_id: newLayerId,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
return { restored_layers: restored, reason: reason };
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
// Every active cost layer for an SKU, oldest first. Layers with
|
|
629
|
+
// `quantity_remaining = 0` are excluded — the accountant reads
|
|
630
|
+
// this to value on-hand inventory, not to audit history.
|
|
631
|
+
currentLayers: async function (input) {
|
|
632
|
+
if (!input || typeof input !== "object") {
|
|
633
|
+
throw new TypeError("cost-layers.currentLayers: input object required");
|
|
634
|
+
}
|
|
635
|
+
var sku = _sku(input.sku);
|
|
636
|
+
return await _activeLayers(sku, "fifo");
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
// Roll up COGS for an entire order. Sums non-reversed
|
|
640
|
+
// attribution rows; refuses when attributions span multiple
|
|
641
|
+
// currencies (the operator's FX policy decides how to combine
|
|
642
|
+
// them — not the primitive).
|
|
643
|
+
cogsForOrder: async function (orderId) {
|
|
644
|
+
var id = _externalId(orderId, "order_id");
|
|
645
|
+
var rows = (await query(
|
|
646
|
+
"SELECT * FROM cogs_attributions WHERE order_id = ?1 AND reversed = 0 " +
|
|
647
|
+
"ORDER BY occurred_at ASC, id ASC",
|
|
648
|
+
[id],
|
|
649
|
+
)).rows;
|
|
650
|
+
if (!rows.length) {
|
|
651
|
+
return {
|
|
652
|
+
order_id: id,
|
|
653
|
+
lines: [],
|
|
654
|
+
total_cogs_minor: 0,
|
|
655
|
+
currency: null,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
var currency = rows[0].currency;
|
|
659
|
+
var lines = {};
|
|
660
|
+
var total = 0;
|
|
661
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
662
|
+
var r = rows[i];
|
|
663
|
+
if (r.currency !== currency) {
|
|
664
|
+
throw new TypeError("cost-layers.cogsForOrder: attributions for order_id=" +
|
|
665
|
+
JSON.stringify(id) + " span multiple currencies (" + currency +
|
|
666
|
+
", " + r.currency + ")");
|
|
667
|
+
}
|
|
668
|
+
if (!lines[r.line_id]) {
|
|
669
|
+
lines[r.line_id] = {
|
|
670
|
+
line_id: r.line_id,
|
|
671
|
+
sku: r.sku,
|
|
672
|
+
qty: 0,
|
|
673
|
+
total_cogs_minor: 0,
|
|
674
|
+
currency: currency,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
var lineCogs = r.qty * r.unit_cost_minor;
|
|
678
|
+
lines[r.line_id].qty += r.qty;
|
|
679
|
+
lines[r.line_id].total_cogs_minor += lineCogs;
|
|
680
|
+
total += lineCogs;
|
|
681
|
+
}
|
|
682
|
+
var lineKeys = Object.keys(lines);
|
|
683
|
+
var lineRows = [];
|
|
684
|
+
for (var k = 0; k < lineKeys.length; k += 1) lineRows.push(lines[lineKeys[k]]);
|
|
685
|
+
return {
|
|
686
|
+
order_id: id,
|
|
687
|
+
lines: lineRows,
|
|
688
|
+
total_cogs_minor: total,
|
|
689
|
+
currency: currency,
|
|
690
|
+
};
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
// Sum non-reversed attributions in `[from, to)`. Optional `sku`
|
|
694
|
+
// filter scopes to a single SKU for per-SKU COGS reports.
|
|
695
|
+
// Returns per-SKU breakdown + grand total. Refuses on mixed
|
|
696
|
+
// currency (consistent with cogsForOrder).
|
|
697
|
+
cogsForPeriod: async function (input) {
|
|
698
|
+
if (!input || typeof input !== "object") {
|
|
699
|
+
throw new TypeError("cost-layers.cogsForPeriod: input object required");
|
|
700
|
+
}
|
|
701
|
+
var from = _epochMs(input.from, "from");
|
|
702
|
+
var to = _epochMs(input.to, "to");
|
|
703
|
+
if (from == null || to == null) {
|
|
704
|
+
throw new TypeError("cost-layers.cogsForPeriod: from and to are required (epoch-ms)");
|
|
705
|
+
}
|
|
706
|
+
if (to <= from) {
|
|
707
|
+
throw new TypeError("cost-layers.cogsForPeriod: to must be > from");
|
|
708
|
+
}
|
|
709
|
+
var rows;
|
|
710
|
+
if (input.sku != null) {
|
|
711
|
+
var sku = _sku(input.sku);
|
|
712
|
+
rows = (await query(
|
|
713
|
+
"SELECT * FROM cogs_attributions WHERE reversed = 0 AND sku = ?1 " +
|
|
714
|
+
"AND occurred_at >= ?2 AND occurred_at < ?3 ORDER BY occurred_at ASC, id ASC",
|
|
715
|
+
[sku, from, to],
|
|
716
|
+
)).rows;
|
|
717
|
+
} else {
|
|
718
|
+
rows = (await query(
|
|
719
|
+
"SELECT * FROM cogs_attributions WHERE reversed = 0 " +
|
|
720
|
+
"AND occurred_at >= ?1 AND occurred_at < ?2 ORDER BY occurred_at ASC, id ASC",
|
|
721
|
+
[from, to],
|
|
722
|
+
)).rows;
|
|
723
|
+
}
|
|
724
|
+
if (!rows.length) {
|
|
725
|
+
return {
|
|
726
|
+
from: from,
|
|
727
|
+
to: to,
|
|
728
|
+
by_sku: [],
|
|
729
|
+
total_cogs_minor: 0,
|
|
730
|
+
currency: null,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
var currency = rows[0].currency;
|
|
734
|
+
var bySku = {};
|
|
735
|
+
var total = 0;
|
|
736
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
737
|
+
var r = rows[i];
|
|
738
|
+
if (r.currency !== currency) {
|
|
739
|
+
throw new TypeError("cost-layers.cogsForPeriod: attributions in window span " +
|
|
740
|
+
"multiple currencies (" + currency + ", " + r.currency +
|
|
741
|
+
") — pass an `sku` filter or reconcile FX before reporting");
|
|
742
|
+
}
|
|
743
|
+
if (!bySku[r.sku]) {
|
|
744
|
+
bySku[r.sku] = {
|
|
745
|
+
sku: r.sku,
|
|
746
|
+
qty: 0,
|
|
747
|
+
total_cogs_minor: 0,
|
|
748
|
+
currency: currency,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
var add = r.qty * r.unit_cost_minor;
|
|
752
|
+
bySku[r.sku].qty += r.qty;
|
|
753
|
+
bySku[r.sku].total_cogs_minor += add;
|
|
754
|
+
total += add;
|
|
755
|
+
}
|
|
756
|
+
var skuKeys = Object.keys(bySku).sort();
|
|
757
|
+
var bySkuRows = [];
|
|
758
|
+
for (var k = 0; k < skuKeys.length; k += 1) bySkuRows.push(bySku[skuKeys[k]]);
|
|
759
|
+
return {
|
|
760
|
+
from: from,
|
|
761
|
+
to: to,
|
|
762
|
+
by_sku: bySkuRows,
|
|
763
|
+
total_cogs_minor: total,
|
|
764
|
+
currency: currency,
|
|
765
|
+
};
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
module.exports = {
|
|
771
|
+
create: create,
|
|
772
|
+
METHODS: METHODS,
|
|
773
|
+
RECEIPT_SOURCES: RECEIPT_SOURCES,
|
|
774
|
+
};
|