@blamejs/blamejs-shop 0.0.65 → 0.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.printQueue
|
|
4
|
+
* @title Print queue — warehouse print job scheduler that decouples
|
|
5
|
+
* order-flow code from the physical printer matrix
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* The warehouse needs packing slips, shipping labels, return labels,
|
|
9
|
+
* and invoices printed at six workstations across two floors. Each
|
|
10
|
+
* workstation has a thermal label printer + a laser printer; some
|
|
11
|
+
* only print 4x6 thermal labels, others only handle Letter / A4
|
|
12
|
+
* paper. A naive "print this slip directly" verb couples the
|
|
13
|
+
* order-flow code to which printer is physically online right now,
|
|
14
|
+
* which paper is loaded, which workstation is unmanned at 3am.
|
|
15
|
+
*
|
|
16
|
+
* The fix is a queue: order-flow code calls `enqueueJob({...})` and
|
|
17
|
+
* walks away; workstations call `claimJob({ station_id, kinds? })`
|
|
18
|
+
* when their operator is ready to print, atomically marking the job
|
|
19
|
+
* in_progress; after the print succeeds the workstation calls
|
|
20
|
+
* `markComplete`. If the printer jams or runs out of paper,
|
|
21
|
+
* `markFailed({ retry: true })` puts the job back at the front of
|
|
22
|
+
* the queue for the next claimant with `retry_count + 1`.
|
|
23
|
+
*
|
|
24
|
+
* Lifecycle (five-state FSM):
|
|
25
|
+
*
|
|
26
|
+
* enqueueJob({ kind, payload_ref, priority, station_filter?,
|
|
27
|
+
* paper_size?, copies?, scheduled_at? })
|
|
28
|
+
* Creates a new `queued` row. `kind` is one of packing_slip /
|
|
29
|
+
* shipping_label / invoice / packing_label / return_label /
|
|
30
|
+
* pickup_slip. `payload_ref` is an opaque operator-supplied
|
|
31
|
+
* reference (typically an order_id, shipment_id, or pre-rendered
|
|
32
|
+
* PDF object key) — the queue itself doesn't interpret it; the
|
|
33
|
+
* claiming workstation resolves the payload via its own render
|
|
34
|
+
* pipeline. `priority` is 0..10 (higher wins). `paper_size`
|
|
35
|
+
* defaults to a kind-appropriate value (labels → thermal_4x6,
|
|
36
|
+
* slips/invoices → letter). `copies` defaults to 1.
|
|
37
|
+
* `station_filter` (optional) restricts the claim pool to a
|
|
38
|
+
* single station_id. `scheduled_at` (optional) defers the job —
|
|
39
|
+
* claims refuse to return rows whose scheduled_at exceeds the
|
|
40
|
+
* current clock. Returns the inserted row.
|
|
41
|
+
*
|
|
42
|
+
* claimJob({ station_id, kinds? })
|
|
43
|
+
* Atomically claims the highest-priority eligible queued row
|
|
44
|
+
* and flips it to `in_progress`. Eligible = `status='queued'`
|
|
45
|
+
* AND `scheduled_at <= now()` AND (`station_filter IS NULL` OR
|
|
46
|
+
* `station_filter = station_id`) AND (`kinds` is omitted OR
|
|
47
|
+
* `kind IN kinds`). Returns the claimed row or null when no job
|
|
48
|
+
* is eligible. The claim writes `claimed_by_station` +
|
|
49
|
+
* `claimed_at` so the audit trail answers "who was working on
|
|
50
|
+
* what when the network went down". Ordering: (priority DESC,
|
|
51
|
+
* scheduled_at ASC, created_at ASC) — highest priority wins,
|
|
52
|
+
* FIFO within a priority band.
|
|
53
|
+
*
|
|
54
|
+
* markComplete({ job_id, station_id })
|
|
55
|
+
* in_progress -> complete. Refuses when the job isn't claimed
|
|
56
|
+
* by the supplied station_id (a different workstation can't
|
|
57
|
+
* complete someone else's job). Stamps `completed_at` +
|
|
58
|
+
* `completed_by_station`.
|
|
59
|
+
*
|
|
60
|
+
* markFailed({ job_id, station_id, reason, retry? })
|
|
61
|
+
* in_progress -> failed. Refuses when the job isn't claimed by
|
|
62
|
+
* the supplied station_id. Stamps `failed_at` + `fail_reason`.
|
|
63
|
+
* When `retry: true`, inserts a fresh `queued` row with the
|
|
64
|
+
* same payload + `retry_count + 1`; the failed row stays on
|
|
65
|
+
* disk for the audit. Returns `{ failed, requeued? }` so the
|
|
66
|
+
* workstation knows whether to expect the same job back on the
|
|
67
|
+
* next poll.
|
|
68
|
+
*
|
|
69
|
+
* cancelJob({ job_id, reason })
|
|
70
|
+
* queued|in_progress -> cancelled. Used when the upstream
|
|
71
|
+
* trigger went away (order cancelled, shipment voided). Refuses
|
|
72
|
+
* cancelling a terminal job (complete / failed / cancelled).
|
|
73
|
+
* Persists `cancel_reason` + `failed_at`.
|
|
74
|
+
*
|
|
75
|
+
* Reads:
|
|
76
|
+
* getJob(job_id) — single row by id
|
|
77
|
+
* jobsForStation({ station_id, status?, limit? }) — claimed-by-this-
|
|
78
|
+
* station view,
|
|
79
|
+
* newest-first
|
|
80
|
+
* pendingByKind({ kind, priority_min? }) — operator dashboard
|
|
81
|
+
* "what's piling up
|
|
82
|
+
* in the queue"
|
|
83
|
+
* stationActivity({ station_id, from, to }) — per-station throughput
|
|
84
|
+
* breakdown
|
|
85
|
+
* dailyMetrics({ from, to }) — aggregate throughput
|
|
86
|
+
* across the window
|
|
87
|
+
*
|
|
88
|
+
* Maintenance:
|
|
89
|
+
* cleanupCompleted({ older_than_hours })
|
|
90
|
+
* Deletes complete + cancelled rows whose completed_at /
|
|
91
|
+
* failed_at is older than the cutoff. Failed-but-requeued rows
|
|
92
|
+
* (retried) are deleted too — the audit lives in the new queued
|
|
93
|
+
* row's `retry_count` lineage. Returns the deleted count.
|
|
94
|
+
*
|
|
95
|
+
* Composition:
|
|
96
|
+
* - b.uuid.v7 — job ids (sortable; the dashboard
|
|
97
|
+
* reads sort by id DESC to get
|
|
98
|
+
* newest-first without a second index).
|
|
99
|
+
* - b.guardUuid — strict UUID validation on every id
|
|
100
|
+
* input.
|
|
101
|
+
*
|
|
102
|
+
* Three-tier input validation: every public verb is either a
|
|
103
|
+
* config-time entry point (factory create) or a defensive
|
|
104
|
+
* request-shape reader. All throw on bad input — no drop-silent
|
|
105
|
+
* hot-path sinks.
|
|
106
|
+
*
|
|
107
|
+
* @primitive printQueue
|
|
108
|
+
* @related pickLists, orderTracking, splitShipments
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
var bShop;
|
|
112
|
+
function _b() {
|
|
113
|
+
if (!bShop) bShop = require("./index");
|
|
114
|
+
return bShop.framework;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- constants ----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
var STATION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
120
|
+
var PAYLOAD_RE = /^[A-Za-z0-9][A-Za-z0-9._:/=+-]{0,255}$/;
|
|
121
|
+
var MAX_REASON = 280;
|
|
122
|
+
var MAX_KINDS = 16;
|
|
123
|
+
var MAX_LIMIT = 500;
|
|
124
|
+
var DEFAULT_LIMIT = 100;
|
|
125
|
+
var MAX_PRIORITY = 10;
|
|
126
|
+
var MAX_COPIES = 99;
|
|
127
|
+
var MAX_RETRY_COUNT = 64;
|
|
128
|
+
|
|
129
|
+
var KIND_ENUM = Object.freeze([
|
|
130
|
+
"packing_slip", "shipping_label", "invoice",
|
|
131
|
+
"packing_label", "return_label", "pickup_slip",
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
var PAPER_SIZE_ENUM = Object.freeze([
|
|
135
|
+
"letter", "a4", "thermal_4x6", "thermal_4x8",
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
var STATUS_ENUM = Object.freeze([
|
|
139
|
+
"queued", "in_progress", "complete", "failed", "cancelled",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
// Default paper size keyed by job kind. Labels go to the thermal
|
|
143
|
+
// printer; slips + invoices go to the laser printer. Operators override
|
|
144
|
+
// per-job when their workflow doesn't match the default (e.g. printing
|
|
145
|
+
// a return label on letter stock because the thermal printer is down).
|
|
146
|
+
var DEFAULT_PAPER_BY_KIND = Object.freeze({
|
|
147
|
+
packing_slip: "letter",
|
|
148
|
+
shipping_label: "thermal_4x6",
|
|
149
|
+
invoice: "letter",
|
|
150
|
+
packing_label: "thermal_4x6",
|
|
151
|
+
return_label: "thermal_4x6",
|
|
152
|
+
pickup_slip: "letter",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---- validators ---------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
function _id(s, label) {
|
|
158
|
+
try {
|
|
159
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
throw new TypeError("print-queue: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function _station(s, label) {
|
|
165
|
+
if (typeof s !== "string" || !STATION_RE.test(s)) {
|
|
166
|
+
throw new TypeError("print-queue: " + (label || "station_id") +
|
|
167
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function _payloadRef(s) {
|
|
171
|
+
if (typeof s !== "string" || !PAYLOAD_RE.test(s)) {
|
|
172
|
+
throw new TypeError("print-queue: payload_ref must be a 1..256 char string of " +
|
|
173
|
+
"alnum + . _ : / = + - characters");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function _kind(s) {
|
|
177
|
+
if (KIND_ENUM.indexOf(s) === -1) {
|
|
178
|
+
throw new TypeError("print-queue: kind must be one of " + KIND_ENUM.join(", ") +
|
|
179
|
+
", got " + JSON.stringify(s));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function _paperSize(s) {
|
|
183
|
+
if (PAPER_SIZE_ENUM.indexOf(s) === -1) {
|
|
184
|
+
throw new TypeError("print-queue: paper_size must be one of " + PAPER_SIZE_ENUM.join(", ") +
|
|
185
|
+
", got " + JSON.stringify(s));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function _status(s) {
|
|
189
|
+
if (STATUS_ENUM.indexOf(s) === -1) {
|
|
190
|
+
throw new TypeError("print-queue: status must be one of " + STATUS_ENUM.join(", ") +
|
|
191
|
+
", got " + JSON.stringify(s));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function _priority(n) {
|
|
195
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_PRIORITY) {
|
|
196
|
+
throw new TypeError("print-queue: priority must be an integer in 0..." + MAX_PRIORITY);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function _copies(n) {
|
|
200
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_COPIES) {
|
|
201
|
+
throw new TypeError("print-queue: copies must be an integer in 1..." + MAX_COPIES);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function _scheduledAt(n) {
|
|
205
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
206
|
+
throw new TypeError("print-queue: scheduled_at must be a non-negative integer (epoch ms)");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function _reason(s, label) {
|
|
210
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_REASON) {
|
|
211
|
+
throw new TypeError("print-queue: " + (label || "reason") +
|
|
212
|
+
" must be a non-empty string ≤ " + MAX_REASON + " chars");
|
|
213
|
+
}
|
|
214
|
+
return s;
|
|
215
|
+
}
|
|
216
|
+
function _limit(n) {
|
|
217
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
218
|
+
throw new TypeError("print-queue: limit must be an integer in 1..." + MAX_LIMIT);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function _hours(n, label) {
|
|
222
|
+
if (typeof n !== "number" || !isFinite(n) || n < 0) {
|
|
223
|
+
throw new TypeError("print-queue: " + label + " must be a non-negative finite number");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function _window(from, to) {
|
|
227
|
+
if (!Number.isInteger(from) || from < 0) {
|
|
228
|
+
throw new TypeError("print-queue: from must be a non-negative integer (epoch ms)");
|
|
229
|
+
}
|
|
230
|
+
if (!Number.isInteger(to) || to < 0) {
|
|
231
|
+
throw new TypeError("print-queue: to must be a non-negative integer (epoch ms)");
|
|
232
|
+
}
|
|
233
|
+
if (from > to) {
|
|
234
|
+
throw new TypeError("print-queue: from must be ≤ to");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function _kindsFilter(arr) {
|
|
238
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
239
|
+
throw new TypeError("print-queue: kinds filter must be a non-empty array when supplied");
|
|
240
|
+
}
|
|
241
|
+
if (arr.length > MAX_KINDS) {
|
|
242
|
+
throw new TypeError("print-queue: kinds filter accepts at most " + MAX_KINDS + " entries");
|
|
243
|
+
}
|
|
244
|
+
var seen = Object.create(null);
|
|
245
|
+
var out = [];
|
|
246
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
247
|
+
_kind(arr[i]);
|
|
248
|
+
if (seen[arr[i]]) {
|
|
249
|
+
throw new TypeError("print-queue: duplicate kind " + JSON.stringify(arr[i]) + " in filter");
|
|
250
|
+
}
|
|
251
|
+
seen[arr[i]] = true;
|
|
252
|
+
out.push(arr[i]);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Monotonic clock — every write that stamps a timestamp uses _now()
|
|
258
|
+
// instead of Date.now() directly so an enqueueJob + immediate claimJob
|
|
259
|
+
// + markComplete sequence never collides on the millisecond boundary.
|
|
260
|
+
// Operators reading the timeline get a strict ordering even on hosts
|
|
261
|
+
// where Date.now() is coarse.
|
|
262
|
+
var _lastTs = 0;
|
|
263
|
+
function _now() {
|
|
264
|
+
var t = Date.now();
|
|
265
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
266
|
+
_lastTs = t;
|
|
267
|
+
return t;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Build a parameterized IN-clause from a fixed-enum list. The values
|
|
271
|
+
// themselves are validated against KIND_ENUM before reaching this
|
|
272
|
+
// function so no operator input lands in the SQL string verbatim; the
|
|
273
|
+
// param indices start at `startIndex` so callers can compose with
|
|
274
|
+
// other ?N placeholders.
|
|
275
|
+
function _inClause(values, startIndex) {
|
|
276
|
+
var placeholders = [];
|
|
277
|
+
for (var i = 0; i < values.length; i += 1) {
|
|
278
|
+
placeholders.push("?" + (startIndex + i));
|
|
279
|
+
}
|
|
280
|
+
return "(" + placeholders.join(", ") + ")";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- factory ------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
function create(opts) {
|
|
286
|
+
opts = opts || {};
|
|
287
|
+
var query = opts.query;
|
|
288
|
+
if (!query) {
|
|
289
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function _getRow(id) {
|
|
293
|
+
var r = await query("SELECT * FROM print_jobs WHERE id = ?1", [id]);
|
|
294
|
+
return r.rows.length ? r.rows[0] : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
KIND_ENUM: KIND_ENUM,
|
|
299
|
+
PAPER_SIZE_ENUM: PAPER_SIZE_ENUM,
|
|
300
|
+
STATUS_ENUM: STATUS_ENUM,
|
|
301
|
+
MAX_PRIORITY: MAX_PRIORITY,
|
|
302
|
+
MAX_COPIES: MAX_COPIES,
|
|
303
|
+
|
|
304
|
+
// Enqueue a new print job. Validates every field, fills defaults
|
|
305
|
+
// (paper_size from DEFAULT_PAPER_BY_KIND[kind], copies = 1,
|
|
306
|
+
// scheduled_at = now), and inserts a `queued` row. Returns the
|
|
307
|
+
// inserted row.
|
|
308
|
+
enqueueJob: async function (input) {
|
|
309
|
+
if (!input || typeof input !== "object") {
|
|
310
|
+
throw new TypeError("print-queue.enqueueJob: input object required");
|
|
311
|
+
}
|
|
312
|
+
_kind(input.kind);
|
|
313
|
+
_payloadRef(input.payload_ref);
|
|
314
|
+
_priority(input.priority);
|
|
315
|
+
if (input.station_filter != null) _station(input.station_filter, "station_filter");
|
|
316
|
+
var paperSize = input.paper_size == null ? DEFAULT_PAPER_BY_KIND[input.kind] : input.paper_size;
|
|
317
|
+
_paperSize(paperSize);
|
|
318
|
+
var copies = input.copies == null ? 1 : input.copies;
|
|
319
|
+
_copies(copies);
|
|
320
|
+
var ts = _now();
|
|
321
|
+
var scheduledAt = input.scheduled_at == null ? ts : input.scheduled_at;
|
|
322
|
+
_scheduledAt(scheduledAt);
|
|
323
|
+
|
|
324
|
+
var id = _b().uuid.v7();
|
|
325
|
+
await query(
|
|
326
|
+
"INSERT INTO print_jobs (id, kind, payload_ref, priority, station_filter, " +
|
|
327
|
+
"paper_size, copies, scheduled_at, status, retry_count, created_at) " +
|
|
328
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'queued', 0, ?9)",
|
|
329
|
+
[id, input.kind, input.payload_ref, input.priority,
|
|
330
|
+
input.station_filter == null ? null : input.station_filter,
|
|
331
|
+
paperSize, copies, scheduledAt, ts],
|
|
332
|
+
);
|
|
333
|
+
return await _getRow(id);
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
// Atomically claim the highest-priority eligible queued job for
|
|
337
|
+
// the supplied station. Returns the claimed row (status flipped
|
|
338
|
+
// to `in_progress`) or null when no job is eligible. The
|
|
339
|
+
// SELECT + UPDATE pair is serialized inside the query
|
|
340
|
+
// implementation (sqlite is single-writer; D1 wraps in a
|
|
341
|
+
// transaction at the worker layer) so two workstations polling
|
|
342
|
+
// simultaneously can't both win the same row.
|
|
343
|
+
claimJob: async function (input) {
|
|
344
|
+
if (!input || typeof input !== "object") {
|
|
345
|
+
throw new TypeError("print-queue.claimJob: input object required");
|
|
346
|
+
}
|
|
347
|
+
_station(input.station_id);
|
|
348
|
+
var ts = _now();
|
|
349
|
+
var kindsFilter = null;
|
|
350
|
+
if (input.kinds != null) kindsFilter = _kindsFilter(input.kinds);
|
|
351
|
+
|
|
352
|
+
// Build the SELECT. station_filter must be null OR match the
|
|
353
|
+
// claiming station; scheduled_at must not exceed the current
|
|
354
|
+
// clock; kinds filter (when supplied) restricts to the
|
|
355
|
+
// workstation's declared capabilities. The ORDER BY matches
|
|
356
|
+
// the (status, priority DESC, scheduled_at) index exactly.
|
|
357
|
+
var sql = "SELECT id FROM print_jobs WHERE status = 'queued' " +
|
|
358
|
+
"AND scheduled_at <= ?1 " +
|
|
359
|
+
"AND (station_filter IS NULL OR station_filter = ?2) ";
|
|
360
|
+
var params = [ts, input.station_id];
|
|
361
|
+
if (kindsFilter) {
|
|
362
|
+
sql += "AND kind IN " + _inClause(kindsFilter, 3) + " ";
|
|
363
|
+
for (var i = 0; i < kindsFilter.length; i += 1) params.push(kindsFilter[i]);
|
|
364
|
+
}
|
|
365
|
+
sql += "ORDER BY priority DESC, scheduled_at ASC, created_at ASC, id ASC LIMIT 1";
|
|
366
|
+
|
|
367
|
+
var sel = await query(sql, params);
|
|
368
|
+
if (!sel.rows.length) return null;
|
|
369
|
+
var jobId = sel.rows[0].id;
|
|
370
|
+
|
|
371
|
+
// Compare-and-swap on status — guard against a racing claimer
|
|
372
|
+
// that already flipped the row in the same millisecond window.
|
|
373
|
+
// When the UPDATE affects zero rows, the other claimer won;
|
|
374
|
+
// return null and let the caller poll again.
|
|
375
|
+
var upd = await query(
|
|
376
|
+
"UPDATE print_jobs SET status = 'in_progress', claimed_by_station = ?1, " +
|
|
377
|
+
"claimed_at = ?2 WHERE id = ?3 AND status = 'queued'",
|
|
378
|
+
[input.station_id, ts, jobId],
|
|
379
|
+
);
|
|
380
|
+
if (upd.rowCount === 0) return null;
|
|
381
|
+
return await _getRow(jobId);
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
// in_progress -> complete. Refuses unless the job is claimed by
|
|
385
|
+
// the supplied station — a different workstation can't complete
|
|
386
|
+
// someone else's job (audit trail integrity).
|
|
387
|
+
markComplete: async function (input) {
|
|
388
|
+
if (!input || typeof input !== "object") {
|
|
389
|
+
throw new TypeError("print-queue.markComplete: input object required");
|
|
390
|
+
}
|
|
391
|
+
var jobId = _id(input.job_id, "job_id");
|
|
392
|
+
_station(input.station_id);
|
|
393
|
+
var row = await _getRow(jobId);
|
|
394
|
+
if (!row) {
|
|
395
|
+
throw new TypeError("print-queue.markComplete: job " + jobId + " not found");
|
|
396
|
+
}
|
|
397
|
+
if (row.status !== "in_progress") {
|
|
398
|
+
throw new TypeError("print-queue.markComplete: job is " + row.status +
|
|
399
|
+
", only in_progress jobs can be completed");
|
|
400
|
+
}
|
|
401
|
+
if (row.claimed_by_station !== input.station_id) {
|
|
402
|
+
throw new TypeError("print-queue.markComplete: job claimed by " +
|
|
403
|
+
JSON.stringify(row.claimed_by_station) + ", not " +
|
|
404
|
+
JSON.stringify(input.station_id));
|
|
405
|
+
}
|
|
406
|
+
var ts = _now();
|
|
407
|
+
await query(
|
|
408
|
+
"UPDATE print_jobs SET status = 'complete', completed_at = ?1, " +
|
|
409
|
+
"completed_by_station = ?2 WHERE id = ?3",
|
|
410
|
+
[ts, input.station_id, jobId],
|
|
411
|
+
);
|
|
412
|
+
return await _getRow(jobId);
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
// in_progress -> failed. Same station-ownership check as
|
|
416
|
+
// markComplete. When `retry: true`, inserts a fresh `queued` row
|
|
417
|
+
// with the same payload + `retry_count + 1` so the job goes back
|
|
418
|
+
// to the head of the priority band. Returns `{ failed: row,
|
|
419
|
+
// requeued?: row }` so the caller can deep-link to the new
|
|
420
|
+
// queued row for monitoring.
|
|
421
|
+
markFailed: async function (input) {
|
|
422
|
+
if (!input || typeof input !== "object") {
|
|
423
|
+
throw new TypeError("print-queue.markFailed: input object required");
|
|
424
|
+
}
|
|
425
|
+
var jobId = _id(input.job_id, "job_id");
|
|
426
|
+
_station(input.station_id);
|
|
427
|
+
var reason = _reason(input.reason, "reason");
|
|
428
|
+
var retry = input.retry === true;
|
|
429
|
+
var row = await _getRow(jobId);
|
|
430
|
+
if (!row) {
|
|
431
|
+
throw new TypeError("print-queue.markFailed: job " + jobId + " not found");
|
|
432
|
+
}
|
|
433
|
+
if (row.status !== "in_progress") {
|
|
434
|
+
throw new TypeError("print-queue.markFailed: job is " + row.status +
|
|
435
|
+
", only in_progress jobs can be marked failed");
|
|
436
|
+
}
|
|
437
|
+
if (row.claimed_by_station !== input.station_id) {
|
|
438
|
+
throw new TypeError("print-queue.markFailed: job claimed by " +
|
|
439
|
+
JSON.stringify(row.claimed_by_station) + ", not " +
|
|
440
|
+
JSON.stringify(input.station_id));
|
|
441
|
+
}
|
|
442
|
+
var ts = _now();
|
|
443
|
+
await query(
|
|
444
|
+
"UPDATE print_jobs SET status = 'failed', failed_at = ?1, fail_reason = ?2 " +
|
|
445
|
+
"WHERE id = ?3",
|
|
446
|
+
[ts, reason, jobId],
|
|
447
|
+
);
|
|
448
|
+
var failed = await _getRow(jobId);
|
|
449
|
+
var out = { failed: failed };
|
|
450
|
+
|
|
451
|
+
if (retry) {
|
|
452
|
+
// Refuse runaway retry loops. retry_count > MAX_RETRY_COUNT
|
|
453
|
+
// surfaces as a TypeError so the workstation operator can't
|
|
454
|
+
// accidentally hammer the queue when the underlying printer
|
|
455
|
+
// is wedged.
|
|
456
|
+
if (row.retry_count >= MAX_RETRY_COUNT) {
|
|
457
|
+
throw new TypeError("print-queue.markFailed: retry_count " + row.retry_count +
|
|
458
|
+
" has reached the cap (" + MAX_RETRY_COUNT + "); the operator " +
|
|
459
|
+
"must clear the underlying failure before requeueing");
|
|
460
|
+
}
|
|
461
|
+
var newId = _b().uuid.v7();
|
|
462
|
+
var ts2 = _now();
|
|
463
|
+
await query(
|
|
464
|
+
"INSERT INTO print_jobs (id, kind, payload_ref, priority, station_filter, " +
|
|
465
|
+
"paper_size, copies, scheduled_at, status, retry_count, created_at) " +
|
|
466
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'queued', ?9, ?10)",
|
|
467
|
+
[newId, row.kind, row.payload_ref, row.priority, row.station_filter,
|
|
468
|
+
row.paper_size, row.copies, ts2, row.retry_count + 1, ts2],
|
|
469
|
+
);
|
|
470
|
+
out.requeued = await _getRow(newId);
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
// queued|in_progress -> cancelled. Used when the upstream trigger
|
|
476
|
+
// went away (order cancelled, shipment voided). Refuses on
|
|
477
|
+
// terminal states so the audit trail can't be retroactively
|
|
478
|
+
// rewritten.
|
|
479
|
+
cancelJob: async function (input) {
|
|
480
|
+
if (!input || typeof input !== "object") {
|
|
481
|
+
throw new TypeError("print-queue.cancelJob: input object required");
|
|
482
|
+
}
|
|
483
|
+
var jobId = _id(input.job_id, "job_id");
|
|
484
|
+
var reason = _reason(input.reason, "reason");
|
|
485
|
+
var row = await _getRow(jobId);
|
|
486
|
+
if (!row) {
|
|
487
|
+
throw new TypeError("print-queue.cancelJob: job " + jobId + " not found");
|
|
488
|
+
}
|
|
489
|
+
if (row.status !== "queued" && row.status !== "in_progress") {
|
|
490
|
+
throw new TypeError("print-queue.cancelJob: job is " + row.status +
|
|
491
|
+
", only queued or in_progress jobs can be cancelled");
|
|
492
|
+
}
|
|
493
|
+
var ts = _now();
|
|
494
|
+
await query(
|
|
495
|
+
"UPDATE print_jobs SET status = 'cancelled', cancel_reason = ?1, failed_at = ?2 " +
|
|
496
|
+
"WHERE id = ?3",
|
|
497
|
+
[reason, ts, jobId],
|
|
498
|
+
);
|
|
499
|
+
return await _getRow(jobId);
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
// Single-row read. Validates id shape so a typo surfaces as a
|
|
503
|
+
// clear refusal instead of a bare null.
|
|
504
|
+
getJob: async function (jobId) {
|
|
505
|
+
var id = _id(jobId, "job_id");
|
|
506
|
+
return await _getRow(id);
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
// Per-station view. Optional status filter; default limit 100,
|
|
510
|
+
// cap 500. Returns newest-first (claimed_at DESC NULLS LAST so
|
|
511
|
+
// unclaimed station_filter rows still surface).
|
|
512
|
+
jobsForStation: async function (input) {
|
|
513
|
+
if (!input || typeof input !== "object") {
|
|
514
|
+
throw new TypeError("print-queue.jobsForStation: input object required");
|
|
515
|
+
}
|
|
516
|
+
_station(input.station_id);
|
|
517
|
+
var hasStatus = input.status != null;
|
|
518
|
+
if (hasStatus) _status(input.status);
|
|
519
|
+
var limit = input.limit == null ? DEFAULT_LIMIT : input.limit;
|
|
520
|
+
_limit(limit);
|
|
521
|
+
|
|
522
|
+
// The query covers both claimed jobs (claimed_by_station =
|
|
523
|
+
// station_id) AND queued station-filtered jobs (station_filter
|
|
524
|
+
// = station_id AND status = 'queued') so the operator UI shows
|
|
525
|
+
// both "what I'm working on" and "what's pinned to my station".
|
|
526
|
+
var sql, params;
|
|
527
|
+
if (hasStatus) {
|
|
528
|
+
sql = "SELECT * FROM print_jobs WHERE " +
|
|
529
|
+
"(claimed_by_station = ?1 OR (station_filter = ?1 AND status = 'queued')) " +
|
|
530
|
+
"AND status = ?2 " +
|
|
531
|
+
"ORDER BY COALESCE(claimed_at, created_at) DESC, id DESC LIMIT ?3";
|
|
532
|
+
params = [input.station_id, input.status, limit];
|
|
533
|
+
} else {
|
|
534
|
+
sql = "SELECT * FROM print_jobs WHERE " +
|
|
535
|
+
"claimed_by_station = ?1 OR (station_filter = ?1 AND status = 'queued') " +
|
|
536
|
+
"ORDER BY COALESCE(claimed_at, created_at) DESC, id DESC LIMIT ?2";
|
|
537
|
+
params = [input.station_id, limit];
|
|
538
|
+
}
|
|
539
|
+
return (await query(sql, params)).rows;
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
// Operator dashboard "what's piling up in the queue". Filters
|
|
543
|
+
// queued + in_progress rows by kind; optional priority_min surfaces
|
|
544
|
+
// the high-priority backlog only. Newest-first within the
|
|
545
|
+
// priority band so the operator sees the most-stale piles at the
|
|
546
|
+
// bottom.
|
|
547
|
+
pendingByKind: async function (input) {
|
|
548
|
+
if (!input || typeof input !== "object") {
|
|
549
|
+
throw new TypeError("print-queue.pendingByKind: input object required");
|
|
550
|
+
}
|
|
551
|
+
_kind(input.kind);
|
|
552
|
+
var priorityMin = 0;
|
|
553
|
+
if (input.priority_min != null) {
|
|
554
|
+
_priority(input.priority_min);
|
|
555
|
+
priorityMin = input.priority_min;
|
|
556
|
+
}
|
|
557
|
+
var r = await query(
|
|
558
|
+
"SELECT * FROM print_jobs WHERE kind = ?1 AND priority >= ?2 " +
|
|
559
|
+
"AND status IN ('queued', 'in_progress') " +
|
|
560
|
+
"ORDER BY priority DESC, scheduled_at ASC, created_at ASC, id ASC",
|
|
561
|
+
[input.kind, priorityMin],
|
|
562
|
+
);
|
|
563
|
+
return r.rows;
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
// Maintenance sweep. Deletes complete + cancelled rows older
|
|
567
|
+
// than the cutoff (now - older_than_hours). Failed-and-requeued
|
|
568
|
+
// rows are deleted too — the audit lives in the new queued row's
|
|
569
|
+
// retry_count lineage. Returns `{ deleted: <count> }` so the
|
|
570
|
+
// operator can monitor the sweep.
|
|
571
|
+
cleanupCompleted: async function (input) {
|
|
572
|
+
if (!input || typeof input !== "object") {
|
|
573
|
+
throw new TypeError("print-queue.cleanupCompleted: input object required");
|
|
574
|
+
}
|
|
575
|
+
_hours(input.older_than_hours, "older_than_hours");
|
|
576
|
+
var cutoff = _now() - Math.floor(input.older_than_hours * 60 * 60 * 1000);
|
|
577
|
+
var r = await query(
|
|
578
|
+
"DELETE FROM print_jobs WHERE status IN ('complete', 'cancelled', 'failed') " +
|
|
579
|
+
"AND COALESCE(completed_at, failed_at, created_at) < ?1",
|
|
580
|
+
[cutoff],
|
|
581
|
+
);
|
|
582
|
+
return { deleted: r.rowCount };
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
// Per-station throughput breakdown across the window. Counts
|
|
586
|
+
// claims (claimed_at in window), completions (completed_at in
|
|
587
|
+
// window), and failures (failed_at in window, excluding
|
|
588
|
+
// cancellations — those aren't "the workstation tried and
|
|
589
|
+
// failed"). The window is half-open: [from, to).
|
|
590
|
+
stationActivity: async function (input) {
|
|
591
|
+
if (!input || typeof input !== "object") {
|
|
592
|
+
throw new TypeError("print-queue.stationActivity: input object required");
|
|
593
|
+
}
|
|
594
|
+
_station(input.station_id);
|
|
595
|
+
_window(input.from, input.to);
|
|
596
|
+
var rClaims = await query(
|
|
597
|
+
"SELECT COUNT(*) AS n FROM print_jobs " +
|
|
598
|
+
"WHERE claimed_by_station = ?1 AND claimed_at >= ?2 AND claimed_at < ?3",
|
|
599
|
+
[input.station_id, input.from, input.to],
|
|
600
|
+
);
|
|
601
|
+
var rComplete = await query(
|
|
602
|
+
"SELECT COUNT(*) AS n FROM print_jobs " +
|
|
603
|
+
"WHERE completed_by_station = ?1 AND completed_at >= ?2 AND completed_at < ?3",
|
|
604
|
+
[input.station_id, input.from, input.to],
|
|
605
|
+
);
|
|
606
|
+
var rFailed = await query(
|
|
607
|
+
"SELECT COUNT(*) AS n FROM print_jobs " +
|
|
608
|
+
"WHERE claimed_by_station = ?1 AND status = 'failed' " +
|
|
609
|
+
"AND failed_at >= ?2 AND failed_at < ?3",
|
|
610
|
+
[input.station_id, input.from, input.to],
|
|
611
|
+
);
|
|
612
|
+
return {
|
|
613
|
+
station_id: input.station_id,
|
|
614
|
+
from: input.from,
|
|
615
|
+
to: input.to,
|
|
616
|
+
claims: Number(rClaims.rows[0].n),
|
|
617
|
+
completed: Number(rComplete.rows[0].n),
|
|
618
|
+
failed: Number(rFailed.rows[0].n),
|
|
619
|
+
};
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
// Aggregate throughput across the window. Returns totals plus
|
|
623
|
+
// per-kind breakdown so the operator can spot e.g. "we printed
|
|
624
|
+
// 4,000 shipping labels but only 12 invoices today". Window is
|
|
625
|
+
// half-open: [from, to).
|
|
626
|
+
dailyMetrics: async function (input) {
|
|
627
|
+
if (!input || typeof input !== "object") {
|
|
628
|
+
throw new TypeError("print-queue.dailyMetrics: input object required");
|
|
629
|
+
}
|
|
630
|
+
_window(input.from, input.to);
|
|
631
|
+
var rEnqueued = await query(
|
|
632
|
+
"SELECT COUNT(*) AS n FROM print_jobs WHERE created_at >= ?1 AND created_at < ?2",
|
|
633
|
+
[input.from, input.to],
|
|
634
|
+
);
|
|
635
|
+
var rCompleted = await query(
|
|
636
|
+
"SELECT COUNT(*) AS n FROM print_jobs " +
|
|
637
|
+
"WHERE status = 'complete' AND completed_at >= ?1 AND completed_at < ?2",
|
|
638
|
+
[input.from, input.to],
|
|
639
|
+
);
|
|
640
|
+
var rFailed = await query(
|
|
641
|
+
"SELECT COUNT(*) AS n FROM print_jobs " +
|
|
642
|
+
"WHERE status = 'failed' AND failed_at >= ?1 AND failed_at < ?2",
|
|
643
|
+
[input.from, input.to],
|
|
644
|
+
);
|
|
645
|
+
var rCancelled = await query(
|
|
646
|
+
"SELECT COUNT(*) AS n FROM print_jobs " +
|
|
647
|
+
"WHERE status = 'cancelled' AND failed_at >= ?1 AND failed_at < ?2",
|
|
648
|
+
[input.from, input.to],
|
|
649
|
+
);
|
|
650
|
+
var rByKind = await query(
|
|
651
|
+
"SELECT kind, COUNT(*) AS n FROM print_jobs " +
|
|
652
|
+
"WHERE status = 'complete' AND completed_at >= ?1 AND completed_at < ?2 " +
|
|
653
|
+
"GROUP BY kind ORDER BY kind ASC",
|
|
654
|
+
[input.from, input.to],
|
|
655
|
+
);
|
|
656
|
+
var byKind = Object.create(null);
|
|
657
|
+
for (var i = 0; i < KIND_ENUM.length; i += 1) byKind[KIND_ENUM[i]] = 0;
|
|
658
|
+
for (var j = 0; j < rByKind.rows.length; j += 1) {
|
|
659
|
+
byKind[rByKind.rows[j].kind] = Number(rByKind.rows[j].n);
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
from: input.from,
|
|
663
|
+
to: input.to,
|
|
664
|
+
enqueued: Number(rEnqueued.rows[0].n),
|
|
665
|
+
completed: Number(rCompleted.rows[0].n),
|
|
666
|
+
failed: Number(rFailed.rows[0].n),
|
|
667
|
+
cancelled: Number(rCancelled.rows[0].n),
|
|
668
|
+
completed_by_kind: byKind,
|
|
669
|
+
};
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
module.exports = {
|
|
675
|
+
create: create,
|
|
676
|
+
KIND_ENUM: KIND_ENUM,
|
|
677
|
+
PAPER_SIZE_ENUM: PAPER_SIZE_ENUM,
|
|
678
|
+
STATUS_ENUM: STATUS_ENUM,
|
|
679
|
+
MAX_PRIORITY: MAX_PRIORITY,
|
|
680
|
+
MAX_COPIES: MAX_COPIES,
|
|
681
|
+
};
|