@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.
- package/CHANGELOG.md +8 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -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/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +35 -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/pixel-events.js +995 -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/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -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,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
|
+
};
|