@blamejs/blamejs-shop 0.0.64 → 0.0.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,774 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.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
+ };