@blamejs/blamejs-shop 0.0.66 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +35 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/operator-roles.js +768 -0
  19. package/lib/order-escalation.js +951 -0
  20. package/lib/order-ratings.js +495 -0
  21. package/lib/order-tags.js +944 -0
  22. package/lib/packing-slips.js +810 -0
  23. package/lib/pixel-events.js +995 -0
  24. package/lib/print-queue.js +681 -0
  25. package/lib/product-qa.js +749 -0
  26. package/lib/promo-bundles.js +835 -0
  27. package/lib/push-notifications.js +937 -0
  28. package/lib/refund-automation.js +853 -0
  29. package/lib/reorder-reminders.js +798 -0
  30. package/lib/robots-config.js +753 -0
  31. package/lib/seller-signup.js +1052 -0
  32. package/lib/sitemap-generator.js +717 -0
  33. package/lib/subscription-gifts.js +710 -0
  34. package/lib/tax-cert-renewals.js +632 -0
  35. package/lib/tier-benefits.js +776 -0
  36. package/lib/vendor/MANIFEST.json +2 -2
  37. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  38. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  39. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  40. package/lib/vendor/blamejs/package.json +1 -1
  41. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  42. package/lib/wishlist-alerts.js +842 -0
  43. package/lib/wishlist-sharing.js +718 -0
  44. package/package.json +1 -1
@@ -0,0 +1,933 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.autoReplenish
4
+ * @title Auto-replenish — scheduler-driven layer that turns reorder
5
+ * threshold fires into auto-submitted POs against bound vendors
6
+ *
7
+ * @intro
8
+ * `reorderThresholds.proposePurchaseOrder` is the OPERATOR-driven
9
+ * layer: an admin opens the reorder queue, picks a vendor, and the
10
+ * primitive returns a draft payload the admin reviews + dispatches.
11
+ *
12
+ * This primitive answers a different question: "I trust vendor X
13
+ * enough to let the framework auto-cut a PO whenever stock crosses
14
+ * the floor, without an admin in the loop." Operators bind a vendor
15
+ * to an auto-replenish POLICY, the scheduler ticks
16
+ * (`tickReplenishment`) on the policy's cadence, and the tick:
17
+ *
18
+ * 1. Calls `reorderThresholds.scanAll({ vendor_slug })` to find
19
+ * reorder candidates whose stock is at-or-below the floor.
20
+ * 2. Aggregates the candidates by vendor.
21
+ * 3. Applies the policy's min/max PO value gates + the
22
+ * max-concurrent-open-PO cap.
23
+ * 4. Composes `purchaseOrders.createDraft` to land the PO row.
24
+ * 5. If the policy doesn't require operator approval, immediately
25
+ * composes `purchaseOrders.submitToVendor` so the PO leaves
26
+ * draft and the vendor's gateway picks it up.
27
+ *
28
+ * A `auto_replenish_runs` row is stamped per (policy, vendor) decision
29
+ * — `proposed` when a candidate set was found but a gate refused
30
+ * (under-min / over-max / cap), `submitted` when the PO left draft,
31
+ * `skipped` when scanAll returned nothing, `failed` when the
32
+ * composition threw.
33
+ *
34
+ * Verbs:
35
+ * definePolicy — register / patch an auto-replenish
36
+ * policy. Identified by `slug`; the
37
+ * vendor binding is optional (a null
38
+ * vendor_slug means the policy aggregates
39
+ * every candidate vendor on the tick).
40
+ * Schedule enum: hourly / daily / weekly
41
+ * — held as metadata; the operator's
42
+ * cron-trigger orchestrator is the actual
43
+ * clock. Defining the same slug a second
44
+ * time patches in place.
45
+ * getPolicy / listPolicies — operator dashboard reads.
46
+ * updatePolicy — partial patch by slug.
47
+ * archivePolicy — soft-delete. Future ticks skip the
48
+ * policy. Idempotent.
49
+ * tickReplenishment — scheduler entry point. Walks every
50
+ * active policy whose schedule matches
51
+ * or whose `last_run_at` is null,
52
+ * applies the candidate aggregation +
53
+ * gates, and stamps a run row per
54
+ * policy. Returns the run summaries so
55
+ * the orchestrator can log + alert.
56
+ * replenishmentHistory — operator read of the run rows in a
57
+ * window. Filter by vendor_slug + status.
58
+ * markPolicyTriggered — called by tickReplenishment after a
59
+ * successful submit; also exposed for
60
+ * operators who need to record an
61
+ * out-of-band auto-fire (e.g. an EDI
62
+ * gateway pushed a PO under a policy and
63
+ * wants the history row stamped).
64
+ *
65
+ * Composition (every dep injected; none required for the factory to
66
+ * construct, but each verb declares what it needs):
67
+ * - query — D1 handle for the policy + runs tables
68
+ * - reorderThresholds — `scanAll({ vendor_slug })` for the
69
+ * candidate sweep
70
+ * - purchaseOrders — `createDraft` + `submitToVendor` for
71
+ * the PO compose
72
+ * - vendors — `getVendor(slug)` to confirm the bound
73
+ * vendor is still active (a policy
74
+ * against an archived vendor is a
75
+ * `skipped` run with `vendor-archived`
76
+ * fail_reason)
77
+ * - b.uuid.v7 — run row ids
78
+ *
79
+ * Three-tier input validation: every public verb is either a
80
+ * config-time entry point (definePolicy, updatePolicy,
81
+ * archivePolicy) — these throw on bad input — or a defensive
82
+ * request-shape reader (getPolicy, listPolicies, tickReplenishment,
83
+ * replenishmentHistory, markPolicyTriggered) — these also throw on
84
+ * bad input. There are no drop-silent hot-path sinks; the per-policy
85
+ * failures inside tickReplenishment land as a `failed` run row with
86
+ * the typed reason, not a drop.
87
+ */
88
+
89
+ var bShop;
90
+ function _b() {
91
+ if (!bShop) bShop = require("./index");
92
+ return bShop.framework;
93
+ }
94
+
95
+ // ---- constants ----------------------------------------------------------
96
+
97
+ var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
98
+ var ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
99
+ var SCHEDULES = Object.freeze(["hourly", "daily", "weekly"]);
100
+ var RUN_STATUSES = Object.freeze(["proposed", "submitted", "skipped", "failed"]);
101
+
102
+ var MAX_PO_VALUE_MINOR = 100000000000; // 1e11 minor units — matches the PO primitive's per-line ceiling × room for multi-line aggregation
103
+ var MAX_CONCURRENT_CAP = 1000; // policy can't bind more than 1000 simultaneously-open POs against one vendor
104
+ var MAX_BATCH_SIZE = 500;
105
+ var DEFAULT_BATCH_SIZE = 100;
106
+ var MAX_LIMIT = 500;
107
+
108
+ // Schedule cadence in milliseconds — used by tickReplenishment to
109
+ // decide whether a policy's `last_run_at` has aged enough to fire
110
+ // again. Stored as constants so tests can read them when constructing
111
+ // "tick just-after-last-run" scenarios.
112
+ var SCHEDULE_INTERVAL_MS = Object.freeze({
113
+ hourly: 60 * 60 * 1000,
114
+ daily: 24 * 60 * 60 * 1000,
115
+ weekly: 7 * 24 * 60 * 60 * 1000,
116
+ });
117
+
118
+ // ---- monotonic clock ----------------------------------------------------
119
+ //
120
+ // `tickReplenishment` can stamp multiple run rows in the same wall-clock
121
+ // millisecond (one per policy in the batch). The history-window read
122
+ // orders by run_at and ties on id; the monotonic step guarantees the
123
+ // run_at values strictly increase so the per-policy ordering is
124
+ // deterministic across replays.
125
+ var _lastTs = 0;
126
+ function _now() {
127
+ var t = Date.now();
128
+ if (t <= _lastTs) { t = _lastTs + 1; }
129
+ _lastTs = t;
130
+ return t;
131
+ }
132
+
133
+ // ---- validators ---------------------------------------------------------
134
+
135
+ function _slug(s, label) {
136
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
137
+ throw new TypeError("auto-replenish: " + label + " must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alnum + dash, 1..64 chars)");
138
+ }
139
+ return s;
140
+ }
141
+
142
+ function _slugOrNull(s, label) {
143
+ if (s == null) return null;
144
+ return _slug(s, label);
145
+ }
146
+
147
+ function _id(s, label) {
148
+ if (typeof s !== "string" || !ID_RE.test(s)) {
149
+ throw new TypeError("auto-replenish: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
150
+ }
151
+ return s;
152
+ }
153
+
154
+ function _idOrNull(s, label) {
155
+ if (s == null) return null;
156
+ return _id(s, label);
157
+ }
158
+
159
+ function _poValue(n, label) {
160
+ if (!Number.isInteger(n) || n < 0 || n > MAX_PO_VALUE_MINOR) {
161
+ throw new TypeError("auto-replenish: " + label + " must be a non-negative integer ≤ " + MAX_PO_VALUE_MINOR);
162
+ }
163
+ return n;
164
+ }
165
+
166
+ function _concurrentCap(n) {
167
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_CONCURRENT_CAP) {
168
+ throw new TypeError("auto-replenish: max_concurrent_open_pos must be a positive integer ≤ " + MAX_CONCURRENT_CAP);
169
+ }
170
+ return n;
171
+ }
172
+
173
+ function _bool(v, label) {
174
+ if (typeof v !== "boolean") {
175
+ throw new TypeError("auto-replenish: " + label + " must be a boolean");
176
+ }
177
+ return v;
178
+ }
179
+
180
+ function _schedule(s) {
181
+ if (typeof s !== "string" || SCHEDULES.indexOf(s) === -1) {
182
+ throw new TypeError("auto-replenish: schedule must be one of " + SCHEDULES.join(", "));
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _runStatus(s) {
188
+ if (typeof s !== "string" || RUN_STATUSES.indexOf(s) === -1) {
189
+ throw new TypeError("auto-replenish: status must be one of " + RUN_STATUSES.join(", "));
190
+ }
191
+ return s;
192
+ }
193
+
194
+ function _epochMs(n, label) {
195
+ if (!Number.isInteger(n) || n < 0) {
196
+ throw new TypeError("auto-replenish: " + label + " must be a non-negative integer (epoch ms)");
197
+ }
198
+ return n;
199
+ }
200
+
201
+ function _batchSize(n) {
202
+ if (n == null) return DEFAULT_BATCH_SIZE;
203
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
204
+ throw new TypeError("auto-replenish: batch_size must be an integer in 1.." + MAX_BATCH_SIZE);
205
+ }
206
+ return n;
207
+ }
208
+
209
+ function _limit(n) {
210
+ if (n == null) return MAX_LIMIT;
211
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
212
+ throw new TypeError("auto-replenish: limit must be an integer in 1.." + MAX_LIMIT);
213
+ }
214
+ return n;
215
+ }
216
+
217
+ function _nonNegInt(n, label) {
218
+ if (!Number.isInteger(n) || n < 0) {
219
+ throw new TypeError("auto-replenish: " + label + " must be a non-negative integer");
220
+ }
221
+ return n;
222
+ }
223
+
224
+ // ---- row hydration ------------------------------------------------------
225
+
226
+ function _shapePolicy(row) {
227
+ if (!row) return null;
228
+ return {
229
+ slug: row.slug,
230
+ vendor_slug: row.vendor_slug == null ? null : row.vendor_slug,
231
+ min_po_value_minor: Number(row.min_po_value_minor),
232
+ max_po_value_minor: Number(row.max_po_value_minor),
233
+ max_concurrent_open_pos: Number(row.max_concurrent_open_pos),
234
+ approval_required: row.approval_required ? true : false,
235
+ schedule: row.schedule,
236
+ last_run_at: row.last_run_at == null ? null : Number(row.last_run_at),
237
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
238
+ created_at: Number(row.created_at),
239
+ updated_at: Number(row.updated_at),
240
+ };
241
+ }
242
+
243
+ function _shapeRun(row) {
244
+ if (!row) return null;
245
+ return {
246
+ id: row.id,
247
+ policy_slug: row.policy_slug,
248
+ po_id: row.po_id == null ? null : row.po_id,
249
+ qty_proposed: Number(row.qty_proposed),
250
+ qty_submitted: Number(row.qty_submitted),
251
+ status: row.status,
252
+ run_at: Number(row.run_at),
253
+ fail_reason: row.fail_reason == null ? null : row.fail_reason,
254
+ };
255
+ }
256
+
257
+ // Compute the value (sum of qty × estimated unit cost) of a candidate
258
+ // set. The scan rows from reorderThresholds carry suggested_qty but no
259
+ // unit cost — auto-replenish keeps the value gate measured in MINOR
260
+ // CURRENCY UNITS via a per-line unit_cost_minor supplied by the
261
+ // candidate row (when the threshold layer adds it) OR by treating the
262
+ // quantity itself as the value proxy when no cost is available. The
263
+ // latter is a documented degradation: operators that want strict
264
+ // minor-currency gating wire a unit-cost source into reorderThresholds.
265
+ function _estimateLineValue(line) {
266
+ if (line.unit_cost_minor != null && Number.isInteger(line.unit_cost_minor)) {
267
+ return line.unit_cost_minor * Number(line.suggested_qty || 0);
268
+ }
269
+ // Fallback: treat one unit as one minor-currency unit so the gate
270
+ // still functions on quantity. Operators surfacing this primitive
271
+ // through documentation are told the gate degrades to quantity when
272
+ // costs aren't supplied.
273
+ return Number(line.suggested_qty || 0);
274
+ }
275
+
276
+ // ---- factory ------------------------------------------------------------
277
+
278
+ function create(opts) {
279
+ opts = opts || {};
280
+
281
+ var reorderThresholds = opts.reorderThresholds || null;
282
+ if (reorderThresholds != null && typeof reorderThresholds !== "object") {
283
+ throw new TypeError("auto-replenish.create: opts.reorderThresholds must be an object or null");
284
+ }
285
+ if (reorderThresholds != null && typeof reorderThresholds.scanAll !== "function") {
286
+ throw new TypeError("auto-replenish.create: opts.reorderThresholds must expose scanAll(...) when provided");
287
+ }
288
+
289
+ var purchaseOrders = opts.purchaseOrders || null;
290
+ if (purchaseOrders != null && typeof purchaseOrders !== "object") {
291
+ throw new TypeError("auto-replenish.create: opts.purchaseOrders must be an object or null");
292
+ }
293
+ if (purchaseOrders != null &&
294
+ (typeof purchaseOrders.createDraft !== "function" ||
295
+ typeof purchaseOrders.submitToVendor !== "function" ||
296
+ typeof purchaseOrders.listPOs !== "function")) {
297
+ throw new TypeError("auto-replenish.create: opts.purchaseOrders must expose createDraft + submitToVendor + listPOs when provided");
298
+ }
299
+
300
+ var vendors = opts.vendors || null;
301
+ if (vendors != null && typeof vendors !== "object") {
302
+ throw new TypeError("auto-replenish.create: opts.vendors must be an object or null");
303
+ }
304
+
305
+ var query = opts.query;
306
+ if (!query) {
307
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
308
+ }
309
+
310
+ async function _getPolicyRaw(slug) {
311
+ var r = await query(
312
+ "SELECT * FROM auto_replenish_policies WHERE slug = ?1",
313
+ [slug],
314
+ );
315
+ return r.rows[0] || null;
316
+ }
317
+
318
+ async function _countConcurrentOpenPOs(vendorSlug) {
319
+ // The concurrent cap counts every PO not yet in a terminal state
320
+ // (closed / cancelled / received) — those still consuming
321
+ // vendor-side capacity. When the policy has a vendor_slug binding,
322
+ // the cap applies to that vendor; when null, the cap applies
323
+ // globally across every open PO.
324
+ if (!purchaseOrders) return 0;
325
+ var openStatuses = ["draft", "submitted", "confirmed", "partially_received"];
326
+ var total = 0;
327
+ for (var i = 0; i < openStatuses.length; i += 1) {
328
+ var listOpts = { status: openStatuses[i] };
329
+ if (vendorSlug != null) listOpts.vendor_slug = vendorSlug;
330
+ var rows = await purchaseOrders.listPOs(listOpts);
331
+ total += rows.length;
332
+ }
333
+ return total;
334
+ }
335
+
336
+ async function _insertRun(row) {
337
+ await query(
338
+ "INSERT INTO auto_replenish_runs " +
339
+ "(id, policy_slug, po_id, qty_proposed, qty_submitted, status, run_at, fail_reason) " +
340
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
341
+ [row.id, row.policy_slug, row.po_id, row.qty_proposed, row.qty_submitted,
342
+ row.status, row.run_at, row.fail_reason],
343
+ );
344
+ }
345
+
346
+ // Execute the per-policy aggregation + compose. Returns the run row
347
+ // (already inserted) so tickReplenishment can collect summaries.
348
+ async function _runPolicyOnce(policy, now) {
349
+ var runId = _b().uuid.v7();
350
+ var runRow = {
351
+ id: runId,
352
+ policy_slug: policy.slug,
353
+ po_id: null,
354
+ qty_proposed: 0,
355
+ qty_submitted: 0,
356
+ status: "skipped",
357
+ run_at: _now(),
358
+ fail_reason: null,
359
+ };
360
+
361
+ // Vendor-archived check — when the policy binds a vendor and the
362
+ // vendors handle is wired, refuse to fire against an archived
363
+ // vendor (the PO would be refused downstream anyway).
364
+ if (policy.vendor_slug != null && vendors != null && typeof vendors.getVendor === "function") {
365
+ var v = await vendors.getVendor(policy.vendor_slug);
366
+ if (!v) {
367
+ runRow.status = "failed";
368
+ runRow.fail_reason = "vendor-not-found";
369
+ await _insertRun(runRow);
370
+ return runRow;
371
+ }
372
+ if (v.status === "archived") {
373
+ runRow.status = "skipped";
374
+ runRow.fail_reason = "vendor-archived";
375
+ await _insertRun(runRow);
376
+ return runRow;
377
+ }
378
+ }
379
+
380
+ // Composition guards — the verb requires both deps wired.
381
+ if (!reorderThresholds) {
382
+ runRow.status = "failed";
383
+ runRow.fail_reason = "reorder-thresholds-dep-missing";
384
+ await _insertRun(runRow);
385
+ return runRow;
386
+ }
387
+ if (!purchaseOrders) {
388
+ runRow.status = "failed";
389
+ runRow.fail_reason = "purchase-orders-dep-missing";
390
+ await _insertRun(runRow);
391
+ return runRow;
392
+ }
393
+
394
+ // Pull reorder candidates filtered to the policy's vendor binding
395
+ // (or every vendor when policy.vendor_slug is null).
396
+ var scanInput = { as_of: now };
397
+ if (policy.vendor_slug != null) scanInput.vendor_slug = policy.vendor_slug;
398
+ var candidates;
399
+ try {
400
+ candidates = await reorderThresholds.scanAll(scanInput);
401
+ } catch (e) {
402
+ runRow.status = "failed";
403
+ runRow.fail_reason = "scan-failed:" + ((e && e.message) || "unknown");
404
+ await _insertRun(runRow);
405
+ return runRow;
406
+ }
407
+
408
+ if (!candidates || candidates.length === 0) {
409
+ runRow.status = "skipped";
410
+ runRow.fail_reason = "no-candidates";
411
+ await _insertRun(runRow);
412
+ return runRow;
413
+ }
414
+
415
+ // Group candidates by vendor_slug. A null-bound policy may pick up
416
+ // candidates from many vendors in one sweep — fire one PO per
417
+ // distinct vendor, each gated independently.
418
+ var groups = Object.create(null);
419
+ for (var i = 0; i < candidates.length; i += 1) {
420
+ var c = candidates[i];
421
+ if (c.vendor_slug == null) continue; // candidates without a vendor binding can't be auto-fired
422
+ if (!groups[c.vendor_slug]) groups[c.vendor_slug] = [];
423
+ groups[c.vendor_slug].push(c);
424
+ }
425
+ var vendorKeys = Object.keys(groups);
426
+ if (vendorKeys.length === 0) {
427
+ runRow.status = "skipped";
428
+ runRow.fail_reason = "no-vendor-bound-candidates";
429
+ await _insertRun(runRow);
430
+ return runRow;
431
+ }
432
+
433
+ // For policies bound to a specific vendor_slug, only that vendor's
434
+ // group fires. For null-bound policies, every vendor's group fires
435
+ // — but the run row aggregates totals across the groups, and the
436
+ // first successful submit's PO id is stamped (further submits are
437
+ // recorded as additional runs via markPolicyTriggered).
438
+ var targetVendors = policy.vendor_slug != null ? [policy.vendor_slug] : vendorKeys;
439
+
440
+ var firstPoId = null;
441
+ var totalProposed = 0;
442
+ var totalSubmitted = 0;
443
+ var firstFailReason = null;
444
+ var firstFailStatus = null;
445
+
446
+ for (var j = 0; j < targetVendors.length; j += 1) {
447
+ var vendorSlug = targetVendors[j];
448
+ var group = groups[vendorSlug];
449
+ if (!group || !group.length) continue;
450
+
451
+ var groupQty = 0;
452
+ var groupValue = 0;
453
+ var poLines = [];
454
+ for (var k = 0; k < group.length; k += 1) {
455
+ var line = group[k];
456
+ var qty = Number(line.suggested_qty) || 0;
457
+ if (qty <= 0) continue;
458
+ groupQty += qty;
459
+ groupValue += _estimateLineValue(line);
460
+ poLines.push({
461
+ sku: line.sku,
462
+ quantity: qty,
463
+ unit_cost_minor: line.unit_cost_minor != null ? line.unit_cost_minor : 0,
464
+ currency: line.currency || "USD",
465
+ });
466
+ }
467
+
468
+ totalProposed += groupQty;
469
+
470
+ if (poLines.length === 0) {
471
+ if (!firstFailReason) {
472
+ firstFailStatus = "skipped";
473
+ firstFailReason = "no-positive-qty-lines";
474
+ }
475
+ continue;
476
+ }
477
+
478
+ if (groupValue < policy.min_po_value_minor) {
479
+ if (!firstFailReason) {
480
+ firstFailStatus = "proposed";
481
+ firstFailReason = "under-min-po-value";
482
+ }
483
+ continue;
484
+ }
485
+ if (groupValue > policy.max_po_value_minor) {
486
+ if (!firstFailReason) {
487
+ firstFailStatus = "proposed";
488
+ firstFailReason = "over-max-po-value";
489
+ }
490
+ continue;
491
+ }
492
+
493
+ var openCount = await _countConcurrentOpenPOs(vendorSlug);
494
+ if (openCount >= policy.max_concurrent_open_pos) {
495
+ if (!firstFailReason) {
496
+ firstFailStatus = "proposed";
497
+ firstFailReason = "concurrent-open-cap-reached";
498
+ }
499
+ continue;
500
+ }
501
+
502
+ // Compose the PO draft. Any throw from createDraft / submitToVendor
503
+ // records a failed run with the typed reason — the framework
504
+ // never leaves the operator guessing why an automated submit
505
+ // didn't land.
506
+ var draft;
507
+ try {
508
+ draft = await purchaseOrders.createDraft({
509
+ vendor_slug: vendorSlug,
510
+ lines: poLines,
511
+ notes: "auto-replenish:" + policy.slug,
512
+ });
513
+ } catch (e) {
514
+ if (!firstFailReason) {
515
+ firstFailStatus = "failed";
516
+ firstFailReason = "create-draft-failed:" + ((e && e.message) || "unknown");
517
+ }
518
+ continue;
519
+ }
520
+
521
+ if (firstPoId == null) firstPoId = draft.id;
522
+
523
+ if (!policy.approval_required) {
524
+ try {
525
+ await purchaseOrders.submitToVendor({
526
+ po_id: draft.id,
527
+ submitted_by: "auto-replenish:" + policy.slug,
528
+ });
529
+ totalSubmitted += groupQty;
530
+ } catch (e) {
531
+ if (!firstFailReason) {
532
+ firstFailStatus = "failed";
533
+ firstFailReason = "submit-failed:" + ((e && e.message) || "unknown");
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ runRow.po_id = firstPoId;
540
+ runRow.qty_proposed = totalProposed;
541
+ runRow.qty_submitted = totalSubmitted;
542
+
543
+ if (firstPoId != null && (totalSubmitted > 0 || policy.approval_required)) {
544
+ // A PO landed. If approval was required, the PO is parked in
545
+ // draft and the run is "proposed"; if approval wasn't required,
546
+ // a successful submit advances to "submitted". A partially-
547
+ // successful submit (some vendors landed, others tripped a gate)
548
+ // still records "submitted" because the operator's PO surface
549
+ // already carries the per-PO truth.
550
+ runRow.status = totalSubmitted > 0 ? "submitted" : "proposed";
551
+ runRow.fail_reason = totalSubmitted > 0 ? null : firstFailReason;
552
+ } else if (firstFailReason) {
553
+ runRow.status = firstFailStatus || "skipped";
554
+ runRow.fail_reason = firstFailReason;
555
+ } else {
556
+ runRow.status = "skipped";
557
+ runRow.fail_reason = "no-candidates";
558
+ }
559
+
560
+ // Stamp last_run_at so the cadence check excludes this policy from
561
+ // the next tick until the schedule interval elapses.
562
+ await query(
563
+ "UPDATE auto_replenish_policies SET last_run_at = ?1, updated_at = ?1 WHERE slug = ?2",
564
+ [runRow.run_at, policy.slug],
565
+ );
566
+
567
+ await _insertRun(runRow);
568
+ return runRow;
569
+ }
570
+
571
+ return {
572
+ SCHEDULES: SCHEDULES.slice(),
573
+ RUN_STATUSES: RUN_STATUSES.slice(),
574
+ SCHEDULE_INTERVAL_MS: SCHEDULE_INTERVAL_MS,
575
+ MAX_PO_VALUE_MINOR: MAX_PO_VALUE_MINOR,
576
+ MAX_CONCURRENT_CAP: MAX_CONCURRENT_CAP,
577
+
578
+ // Register / patch an auto-replenish policy. The slug is the
579
+ // primary key — re-defining the same slug patches every field in
580
+ // place (operators surfacing this through an admin UI write the
581
+ // same slug repeatedly on each "save").
582
+ definePolicy: async function (input) {
583
+ if (!input || typeof input !== "object") {
584
+ throw new TypeError("auto-replenish.definePolicy: input object required");
585
+ }
586
+ var slug = _slug(input.slug, "slug");
587
+ var vendorSlug = _slugOrNull(input.vendor_slug, "vendor_slug");
588
+ var minVal = _poValue(input.min_po_value_minor, "min_po_value_minor");
589
+ var maxVal = _poValue(input.max_po_value_minor, "max_po_value_minor");
590
+ if (maxVal < minVal) {
591
+ throw new TypeError("auto-replenish.definePolicy: max_po_value_minor (" +
592
+ maxVal + ") must be ≥ min_po_value_minor (" + minVal + ")");
593
+ }
594
+ var maxConcurrent = _concurrentCap(input.max_concurrent_open_pos);
595
+ var approvalRequired = _bool(input.approval_required, "approval_required");
596
+ var schedule = _schedule(input.schedule);
597
+ var ts = _now();
598
+
599
+ var existing = await _getPolicyRaw(slug);
600
+ if (existing) {
601
+ await query(
602
+ "UPDATE auto_replenish_policies SET vendor_slug = ?1, min_po_value_minor = ?2, " +
603
+ "max_po_value_minor = ?3, max_concurrent_open_pos = ?4, approval_required = ?5, " +
604
+ "schedule = ?6, archived_at = NULL, updated_at = ?7 WHERE slug = ?8",
605
+ [vendorSlug, minVal, maxVal, maxConcurrent, approvalRequired ? 1 : 0,
606
+ schedule, ts, slug],
607
+ );
608
+ return _shapePolicy(await _getPolicyRaw(slug));
609
+ }
610
+
611
+ await query(
612
+ "INSERT INTO auto_replenish_policies " +
613
+ "(slug, vendor_slug, min_po_value_minor, max_po_value_minor, " +
614
+ " max_concurrent_open_pos, approval_required, schedule, last_run_at, " +
615
+ " archived_at, created_at, updated_at) " +
616
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL, ?8, ?8)",
617
+ [slug, vendorSlug, minVal, maxVal, maxConcurrent,
618
+ approvalRequired ? 1 : 0, schedule, ts],
619
+ );
620
+ return _shapePolicy(await _getPolicyRaw(slug));
621
+ },
622
+
623
+ getPolicy: async function (slug) {
624
+ _slug(slug, "slug");
625
+ return _shapePolicy(await _getPolicyRaw(slug));
626
+ },
627
+
628
+ listPolicies: async function (listOpts) {
629
+ listOpts = listOpts || {};
630
+ var clauses = [];
631
+ var params = [];
632
+ var idx = 1;
633
+ if (!listOpts.include_archived) {
634
+ clauses.push("archived_at IS NULL");
635
+ }
636
+ if (listOpts.vendor_slug !== undefined) {
637
+ if (listOpts.vendor_slug === null) {
638
+ clauses.push("vendor_slug IS NULL");
639
+ } else {
640
+ _slug(listOpts.vendor_slug, "vendor_slug");
641
+ clauses.push("vendor_slug = ?" + idx);
642
+ params.push(listOpts.vendor_slug);
643
+ idx += 1;
644
+ }
645
+ }
646
+ if (listOpts.schedule !== undefined) {
647
+ _schedule(listOpts.schedule);
648
+ clauses.push("schedule = ?" + idx);
649
+ params.push(listOpts.schedule);
650
+ idx += 1;
651
+ }
652
+ var where = clauses.length ? "WHERE " + clauses.join(" AND ") : "";
653
+ var r = await query(
654
+ "SELECT * FROM auto_replenish_policies " + where +
655
+ " ORDER BY slug ASC",
656
+ params,
657
+ );
658
+ return r.rows.map(_shapePolicy);
659
+ },
660
+
661
+ updatePolicy: async function (slug, patch) {
662
+ _slug(slug, "slug");
663
+ if (!patch || typeof patch !== "object") {
664
+ throw new TypeError("auto-replenish.updatePolicy: patch object required");
665
+ }
666
+ var existing = await _getPolicyRaw(slug);
667
+ if (!existing) {
668
+ throw new TypeError("auto-replenish.updatePolicy: policy " +
669
+ JSON.stringify(slug) + " not found");
670
+ }
671
+ var nextMin = Number(existing.min_po_value_minor);
672
+ var nextMax = Number(existing.max_po_value_minor);
673
+
674
+ var sets = [];
675
+ var params = [];
676
+ var idx = 1;
677
+ if (Object.prototype.hasOwnProperty.call(patch, "vendor_slug")) {
678
+ var vs = _slugOrNull(patch.vendor_slug, "vendor_slug");
679
+ sets.push("vendor_slug = ?" + idx); params.push(vs); idx += 1;
680
+ }
681
+ if (Object.prototype.hasOwnProperty.call(patch, "min_po_value_minor")) {
682
+ _poValue(patch.min_po_value_minor, "min_po_value_minor");
683
+ nextMin = patch.min_po_value_minor;
684
+ sets.push("min_po_value_minor = ?" + idx);
685
+ params.push(patch.min_po_value_minor);
686
+ idx += 1;
687
+ }
688
+ if (Object.prototype.hasOwnProperty.call(patch, "max_po_value_minor")) {
689
+ _poValue(patch.max_po_value_minor, "max_po_value_minor");
690
+ nextMax = patch.max_po_value_minor;
691
+ sets.push("max_po_value_minor = ?" + idx);
692
+ params.push(patch.max_po_value_minor);
693
+ idx += 1;
694
+ }
695
+ if (nextMax < nextMin) {
696
+ throw new TypeError("auto-replenish.updatePolicy: max_po_value_minor (" +
697
+ nextMax + ") must be ≥ min_po_value_minor (" + nextMin + ")");
698
+ }
699
+ if (Object.prototype.hasOwnProperty.call(patch, "max_concurrent_open_pos")) {
700
+ _concurrentCap(patch.max_concurrent_open_pos);
701
+ sets.push("max_concurrent_open_pos = ?" + idx);
702
+ params.push(patch.max_concurrent_open_pos);
703
+ idx += 1;
704
+ }
705
+ if (Object.prototype.hasOwnProperty.call(patch, "approval_required")) {
706
+ _bool(patch.approval_required, "approval_required");
707
+ sets.push("approval_required = ?" + idx);
708
+ params.push(patch.approval_required ? 1 : 0);
709
+ idx += 1;
710
+ }
711
+ if (Object.prototype.hasOwnProperty.call(patch, "schedule")) {
712
+ _schedule(patch.schedule);
713
+ sets.push("schedule = ?" + idx); params.push(patch.schedule); idx += 1;
714
+ }
715
+ if (sets.length === 0) return _shapePolicy(existing);
716
+ sets.push("updated_at = ?" + idx); params.push(_now()); idx += 1;
717
+ params.push(slug);
718
+ await query(
719
+ "UPDATE auto_replenish_policies SET " + sets.join(", ") + " WHERE slug = ?" + idx,
720
+ params,
721
+ );
722
+ return _shapePolicy(await _getPolicyRaw(slug));
723
+ },
724
+
725
+ archivePolicy: async function (slug) {
726
+ _slug(slug, "slug");
727
+ var existing = await _getPolicyRaw(slug);
728
+ if (!existing) {
729
+ throw new TypeError("auto-replenish.archivePolicy: policy " +
730
+ JSON.stringify(slug) + " not found");
731
+ }
732
+ if (existing.archived_at != null) return _shapePolicy(existing);
733
+ var ts = _now();
734
+ await query(
735
+ "UPDATE auto_replenish_policies SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
736
+ [ts, slug],
737
+ );
738
+ return _shapePolicy(await _getPolicyRaw(slug));
739
+ },
740
+
741
+ // Scheduler entry point. Walks active policies that are due (the
742
+ // schedule interval has elapsed since last_run_at, or last_run_at
743
+ // is null), composes the per-policy run, and returns the run
744
+ // summaries. The orchestrator (Workers Cron Trigger / external
745
+ // cron) calls this on a tight cadence (e.g. every minute); the
746
+ // policy's `schedule` field then gates which policies actually
747
+ // fire on a given tick.
748
+ tickReplenishment: async function (input) {
749
+ input = input || {};
750
+ var now = input.now == null ? _now() : _epochMs(input.now, "now");
751
+ var batchSize = _batchSize(input.batch_size);
752
+
753
+ var r = await query(
754
+ "SELECT * FROM auto_replenish_policies WHERE archived_at IS NULL " +
755
+ "ORDER BY slug ASC LIMIT ?1",
756
+ [batchSize],
757
+ );
758
+ var rows = r.rows;
759
+ var summaries = [];
760
+ for (var i = 0; i < rows.length; i += 1) {
761
+ var policy = _shapePolicy(rows[i]);
762
+ var interval = SCHEDULE_INTERVAL_MS[policy.schedule];
763
+ if (policy.last_run_at != null && (now - policy.last_run_at) < interval) {
764
+ continue;
765
+ }
766
+ var runRow = await _runPolicyOnce(policy, now);
767
+ summaries.push(_shapeRun(runRow));
768
+ }
769
+ return summaries;
770
+ },
771
+
772
+ replenishmentHistory: async function (input) {
773
+ if (!input || typeof input !== "object") {
774
+ throw new TypeError("auto-replenish.replenishmentHistory: input object required");
775
+ }
776
+ var from = _epochMs(input.from, "from");
777
+ var to = _epochMs(input.to, "to");
778
+ if (to < from) {
779
+ throw new TypeError("auto-replenish.replenishmentHistory: to (" +
780
+ to + ") must be ≥ from (" + from + ")");
781
+ }
782
+ var clauses = ["run_at >= ?1", "run_at <= ?2"];
783
+ var params = [from, to];
784
+ var idx = 3;
785
+ if (input.vendor_slug !== undefined) {
786
+ _slug(input.vendor_slug, "vendor_slug");
787
+ // Join through the policy row so the operator can filter by
788
+ // the vendor binding without storing it redundantly on the run
789
+ // row.
790
+ clauses.push("policy_slug IN (SELECT slug FROM auto_replenish_policies WHERE vendor_slug = ?" + idx + ")");
791
+ params.push(input.vendor_slug);
792
+ idx += 1;
793
+ }
794
+ if (input.status !== undefined) {
795
+ _runStatus(input.status);
796
+ clauses.push("status = ?" + idx);
797
+ params.push(input.status);
798
+ idx += 1;
799
+ }
800
+ var limit = _limit(input.limit);
801
+ params.push(limit);
802
+ var sql = "SELECT * FROM auto_replenish_runs WHERE " + clauses.join(" AND ") +
803
+ " ORDER BY run_at DESC, id DESC LIMIT ?" + idx;
804
+ var rr = await query(sql, params);
805
+ return rr.rows.map(_shapeRun);
806
+ },
807
+
808
+ markPolicyTriggered: async function (input) {
809
+ if (!input || typeof input !== "object") {
810
+ throw new TypeError("auto-replenish.markPolicyTriggered: input object required");
811
+ }
812
+ var policySlug = _slug(input.policy_slug, "policy_slug");
813
+ var poId = _idOrNull(input.po_id, "po_id");
814
+ var qtyProposed = _nonNegInt(input.qty_proposed, "qty_proposed");
815
+ var qtySubmitted = _nonNegInt(input.qty_submitted, "qty_submitted");
816
+ if (qtySubmitted > qtyProposed) {
817
+ throw new TypeError("auto-replenish.markPolicyTriggered: qty_submitted (" +
818
+ qtySubmitted + ") must be ≤ qty_proposed (" + qtyProposed + ")");
819
+ }
820
+ var existing = await _getPolicyRaw(policySlug);
821
+ if (!existing) {
822
+ throw new TypeError("auto-replenish.markPolicyTriggered: policy " +
823
+ JSON.stringify(policySlug) + " not found");
824
+ }
825
+ var runRow = {
826
+ id: _b().uuid.v7(),
827
+ policy_slug: policySlug,
828
+ po_id: poId,
829
+ qty_proposed: qtyProposed,
830
+ qty_submitted: qtySubmitted,
831
+ status: qtySubmitted > 0 ? "submitted" : "proposed",
832
+ run_at: _now(),
833
+ fail_reason: null,
834
+ };
835
+ await _insertRun(runRow);
836
+ await query(
837
+ "UPDATE auto_replenish_policies SET last_run_at = ?1, updated_at = ?1 WHERE slug = ?2",
838
+ [runRow.run_at, policySlug],
839
+ );
840
+ return _shapeRun(runRow);
841
+ },
842
+ };
843
+ }
844
+
845
+ // Smoke-callable run() — exercises the factory shape against an
846
+ // in-memory query stub so the release pipeline can confirm the module
847
+ // loads + composes without a live D1 or migration. The stub round-
848
+ // trips definePolicy → getPolicy → markPolicyTriggered →
849
+ // replenishmentHistory; the actual scheduler path (tickReplenishment
850
+ // composing reorderThresholds + purchaseOrders) lives in the layer-1
851
+ // state test where the full sqlite + dep graph is wired.
852
+ async function run() {
853
+ var policies = {};
854
+ var runs = [];
855
+ var q = async function (sql, params) {
856
+ params = params || [];
857
+ var verb = sql.replace(/^\s+/, "").split(/\s+/)[0].toUpperCase();
858
+ if (verb === "SELECT" && /FROM auto_replenish_policies/.test(sql) && /slug\s*=\s*\?1/.test(sql)) {
859
+ var p = policies[params[0]];
860
+ return { rows: p ? [p] : [], rowCount: p ? 1 : 0 };
861
+ }
862
+ if (verb === "SELECT" && /FROM auto_replenish_policies/.test(sql)) {
863
+ var out = [];
864
+ var keys = Object.keys(policies);
865
+ for (var k = 0; k < keys.length; k += 1) {
866
+ if (policies[keys[k]].archived_at == null) out.push(policies[keys[k]]);
867
+ }
868
+ return { rows: out, rowCount: out.length };
869
+ }
870
+ if (verb === "INSERT" && /auto_replenish_policies/.test(sql)) {
871
+ policies[params[0]] = {
872
+ slug: params[0],
873
+ vendor_slug: params[1],
874
+ min_po_value_minor: params[2],
875
+ max_po_value_minor: params[3],
876
+ max_concurrent_open_pos: params[4],
877
+ approval_required: params[5],
878
+ schedule: params[6],
879
+ last_run_at: null,
880
+ archived_at: null,
881
+ created_at: params[7],
882
+ updated_at: params[7],
883
+ };
884
+ return { rows: [], rowCount: 1 };
885
+ }
886
+ if (verb === "UPDATE" && /auto_replenish_policies/.test(sql)) {
887
+ var slug = params[params.length - 1];
888
+ var ex = policies[slug];
889
+ if (ex) ex.updated_at = params[params.length - 2];
890
+ return { rows: [], rowCount: ex ? 1 : 0 };
891
+ }
892
+ if (verb === "INSERT" && /auto_replenish_runs/.test(sql)) {
893
+ runs.push({
894
+ id: params[0],
895
+ policy_slug: params[1],
896
+ po_id: params[2],
897
+ qty_proposed: params[3],
898
+ qty_submitted: params[4],
899
+ status: params[5],
900
+ run_at: params[6],
901
+ fail_reason: params[7],
902
+ });
903
+ return { rows: [], rowCount: 1 };
904
+ }
905
+ if (verb === "SELECT" && /FROM auto_replenish_runs/.test(sql)) {
906
+ return { rows: runs.slice(), rowCount: runs.length };
907
+ }
908
+ return { rows: [], rowCount: 0 };
909
+ };
910
+ var ar = create({ query: q });
911
+ await ar.definePolicy({
912
+ slug: "smoke-policy",
913
+ vendor_slug: "acme-supplies",
914
+ min_po_value_minor: 1000,
915
+ max_po_value_minor: 1000000,
916
+ max_concurrent_open_pos: 5,
917
+ approval_required: false,
918
+ schedule: "daily",
919
+ });
920
+ var p = await ar.getPolicy("smoke-policy");
921
+ if (!p || p.slug !== "smoke-policy") {
922
+ throw new Error("auto-replenish.run: smoke definePolicy round-trip failed");
923
+ }
924
+ return { ok: true };
925
+ }
926
+
927
+ module.exports = {
928
+ create: create,
929
+ run: run,
930
+ SCHEDULES: SCHEDULES,
931
+ RUN_STATUSES: RUN_STATUSES,
932
+ SCHEDULE_INTERVAL_MS: SCHEDULE_INTERVAL_MS,
933
+ };