@arcforgelabs/flow 2026.10.30 → 2026.10.32-beta.1
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/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +75 -3
- package/server/core/price-list.js +348 -0
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +20 -1
- package/server/integrations/seazona/feed.js +23 -1
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +37 -1
- package/server/integrations/seazona/lanes.js +186 -50
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -17
- package/server/integrations/xero/index.js +36 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { hashOf, parseSeazonaDate, text } from '../map.js';
|
|
2
|
+
|
|
3
|
+
// Seazona's CSV export, read into Flow fields and grouped into jobs. Pure: no
|
|
4
|
+
// database and no network. docs/SEAZONA.md, "History import", lists the
|
|
5
|
+
// headers Flow knows.
|
|
6
|
+
|
|
7
|
+
// Flow field -> the headers Seazona's export may use for it. Headers compare
|
|
8
|
+
// without case, spaces or punctuation, so "Invoice #" and "invoice no" match.
|
|
9
|
+
// "Client Name" is left out on purpose: in Seazona it can mean the doctor or
|
|
10
|
+
// the practice, so a person maps it with `columns` when the file has it.
|
|
11
|
+
export const INVOICE_LINE_COLUMNS = Object.freeze({
|
|
12
|
+
invoiceNumber: ['invoice', 'invoice #', 'invoice number', 'invoice no', 'inv #', 'inv no'],
|
|
13
|
+
caseRef: ['case', 'case #', 'case number', 'case no', 'case id', 'order', 'order #', 'order number', 'order no', 'order id'],
|
|
14
|
+
jobDate: ['invoice date', 'date', 'date invoiced', 'invoiced'],
|
|
15
|
+
dueDate: ['due date', 'due'],
|
|
16
|
+
shippedDate: ['ship date', 'shipped', 'shipped date', 'date shipped'],
|
|
17
|
+
company: ['company name', 'company', 'practice', 'practice name', 'account name'],
|
|
18
|
+
accountNumber: ['account #', 'account number', 'account no'],
|
|
19
|
+
doctorName: ['doctor name', 'doctor', 'dentist', 'practitioner'],
|
|
20
|
+
patient: ['patient', 'patient name'],
|
|
21
|
+
status: ['status', 'case status'],
|
|
22
|
+
item: ['item', 'product', 'description', 'item description', 'product name', 'service'],
|
|
23
|
+
itemCode: ['item code', 'code', 'product code'],
|
|
24
|
+
quantity: ['qty', 'quantity', 'units'],
|
|
25
|
+
itemAmount: ['item amount', 'amount', 'line amount', 'line total', 'price'],
|
|
26
|
+
itemTaxable: ['item taxable', 'taxable'],
|
|
27
|
+
total: ['total', 'invoice total'],
|
|
28
|
+
tooth: ['tooth', 'teeth', 'tooth #'],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// A file is read only when it has these, and at least one date.
|
|
32
|
+
const REQUIRED = ['company', 'item', 'itemAmount'];
|
|
33
|
+
const DATES = ['jobDate', 'dueDate', 'shippedDate'];
|
|
34
|
+
|
|
35
|
+
const squash = (header) => text(header).toLowerCase().replace(/[^a-z0-9#]/g, '').replace(/#/g, 'number');
|
|
36
|
+
|
|
37
|
+
// Answers { columns: field -> header, ignored: [header], missing: [field] }.
|
|
38
|
+
// `override` (field -> header) wins over the guess; a null value unmaps it.
|
|
39
|
+
export function detectColumns(headers, override = {}) {
|
|
40
|
+
const byKey = new Map(headers.map((h) => [squash(h), h]));
|
|
41
|
+
const columns = {};
|
|
42
|
+
for (const [field, aliases] of Object.entries(INVOICE_LINE_COLUMNS)) {
|
|
43
|
+
const found = aliases.map((alias) => byKey.get(squash(alias))).find(Boolean);
|
|
44
|
+
if (found) columns[field] = found;
|
|
45
|
+
}
|
|
46
|
+
for (const [field, header] of Object.entries(override ?? {})) {
|
|
47
|
+
if (!(field in INVOICE_LINE_COLUMNS)) throw Object.assign(new Error(`Unknown field ${field}`), { code: 'invalid' });
|
|
48
|
+
if (header === null) delete columns[field];
|
|
49
|
+
else if (headers.includes(header)) columns[field] = header;
|
|
50
|
+
else throw Object.assign(new Error(`The file has no column "${header}"`), { code: 'invalid' });
|
|
51
|
+
}
|
|
52
|
+
const used = new Set(Object.values(columns));
|
|
53
|
+
const missing = REQUIRED.filter((field) => !columns[field]);
|
|
54
|
+
if (!DATES.some((field) => columns[field])) missing.push('date');
|
|
55
|
+
return { columns, ignored: headers.filter((h) => h && !used.has(h)), missing };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// "$1,234.50", "1234.5", "(12.00)" and "-12" to integer cents.
|
|
59
|
+
export function moneyCents(value) {
|
|
60
|
+
const raw = text(value);
|
|
61
|
+
if (!raw) return null;
|
|
62
|
+
const negative = /^\(.*\)$/.test(raw) || raw.startsWith('-');
|
|
63
|
+
const digits = raw.replace(/[()$,\s-]/g, '').replace(/^[A-Z]{3}/i, '');
|
|
64
|
+
if (!/^\d*(\.\d+)?$/.test(digits) || digits === '' || digits === '.') return undefined;
|
|
65
|
+
const cents = Math.round(Number(digits) * 100);
|
|
66
|
+
return negative ? -cents : cents;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function yesNo(value) {
|
|
70
|
+
const raw = text(value).toLowerCase();
|
|
71
|
+
if (['y', 'yes', 'true', '1', 'taxable', 'gst'].includes(raw)) return true;
|
|
72
|
+
if (['n', 'no', 'false', '0', 'exempt', 'gst free', 'none'].includes(raw)) return false;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One CSV row to Flow fields and the problems that stop it. The patient field
|
|
77
|
+
// is read for grouping; the caller drops it unless the installation keeps a
|
|
78
|
+
// patient reference.
|
|
79
|
+
export function readRow(cells, headers, columns) {
|
|
80
|
+
const cell = (field) => {
|
|
81
|
+
const header = columns[field];
|
|
82
|
+
if (!header) return '';
|
|
83
|
+
return text(cells[headers.indexOf(header)]);
|
|
84
|
+
};
|
|
85
|
+
const problems = [];
|
|
86
|
+
const fields = {
|
|
87
|
+
invoiceNumber: cell('invoiceNumber') || null,
|
|
88
|
+
caseRef: cell('caseRef') || null,
|
|
89
|
+
company: cell('company') || null,
|
|
90
|
+
accountNumber: cell('accountNumber') || null,
|
|
91
|
+
doctorName: cell('doctorName') || null,
|
|
92
|
+
patient: cell('patient') || null,
|
|
93
|
+
status: cell('status') || null,
|
|
94
|
+
item: cell('item') || null,
|
|
95
|
+
itemCode: cell('itemCode') || null,
|
|
96
|
+
tooth: cell('tooth') || null,
|
|
97
|
+
};
|
|
98
|
+
for (const field of DATES) {
|
|
99
|
+
const raw = cell(field);
|
|
100
|
+
fields[field] = raw ? parseSeazonaDate(raw) : null;
|
|
101
|
+
if (raw && !fields[field]) problems.push(`bad_${field}`);
|
|
102
|
+
}
|
|
103
|
+
if (!DATES.some((field) => fields[field])) problems.push('missing_date');
|
|
104
|
+
if (!fields.company) problems.push('missing_company');
|
|
105
|
+
if (!fields.item) problems.push('missing_item');
|
|
106
|
+
const amount = moneyCents(cell('itemAmount'));
|
|
107
|
+
if (amount === undefined || amount === null) problems.push(amount === null ? 'missing_amount' : 'bad_amount');
|
|
108
|
+
fields.itemAmountCents = typeof amount === 'number' ? amount : null;
|
|
109
|
+
const total = moneyCents(cell('total'));
|
|
110
|
+
if (total === undefined) problems.push('bad_total');
|
|
111
|
+
fields.totalCents = typeof total === 'number' ? total : null;
|
|
112
|
+
const quantity = cell('quantity');
|
|
113
|
+
fields.quantity = quantity && Number.isFinite(Number(quantity)) ? Number(quantity) : null;
|
|
114
|
+
fields.itemTaxable = columns.itemTaxable ? yesNo(cell('itemTaxable')) : null;
|
|
115
|
+
return { fields, problems };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sameText = (a, b) => text(a).toLowerCase() === text(b).toLowerCase();
|
|
119
|
+
|
|
120
|
+
// Where a row's job is found: its invoice number, else its case number, else
|
|
121
|
+
// the old app's grouping (date, practice, doctor and patient). The patient
|
|
122
|
+
// only separates jobs inside this file; it is never part of the stored key.
|
|
123
|
+
function groupOf(fields) {
|
|
124
|
+
if (fields.invoiceNumber) return `inv:${fields.invoiceNumber}`;
|
|
125
|
+
if (fields.caseRef) return `case:${fields.caseRef}`;
|
|
126
|
+
const date = fields.jobDate ?? fields.dueDate ?? fields.shippedDate;
|
|
127
|
+
return `grp:${[date, fields.company, fields.doctorName, fields.patient].map((v) => text(v).toLowerCase()).join('|')}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const withGst = (cents) => Math.round(cents * 1.1);
|
|
131
|
+
|
|
132
|
+
// Rows (each { rowNumber, fields, problems }) to jobs. Rows with problems
|
|
133
|
+
// join no job. Answers { jobs, rowKeys: Map(rowNumber -> jobKey) }. Each job
|
|
134
|
+
// carries its findings: { code, blocking }.
|
|
135
|
+
export function groupJobs(rows) {
|
|
136
|
+
const groups = new Map();
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
if (row.problems.length) continue;
|
|
139
|
+
const group = groupOf(row.fields);
|
|
140
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
141
|
+
groups.get(group).push(row);
|
|
142
|
+
}
|
|
143
|
+
const jobs = [];
|
|
144
|
+
const rowKeys = new Map();
|
|
145
|
+
const taken = new Map();
|
|
146
|
+
for (const [group, members] of groups) {
|
|
147
|
+
const first = members[0].fields;
|
|
148
|
+
const findings = [];
|
|
149
|
+
for (const field of ['company', 'doctorName']) {
|
|
150
|
+
if (members.some((m) => !sameText(m.fields[field], first[field]))) findings.push({ code: `mixed_${field}`, blocking: true });
|
|
151
|
+
}
|
|
152
|
+
const totals = [...new Set(members.map((m) => m.fields.totalCents).filter((t) => t !== null))];
|
|
153
|
+
if (totals.length > 1) findings.push({ code: 'conflicting_totals', blocking: true });
|
|
154
|
+
const lines = members.map((m) => ({
|
|
155
|
+
description: m.fields.item,
|
|
156
|
+
code: m.fields.itemCode,
|
|
157
|
+
amountCents: m.fields.itemAmountCents,
|
|
158
|
+
...(m.fields.quantity !== null ? { quantity: m.fields.quantity } : {}),
|
|
159
|
+
...(m.fields.itemTaxable !== null ? { taxable: m.fields.itemTaxable } : {}),
|
|
160
|
+
...(m.fields.tooth ? { tooth: m.fields.tooth } : {}),
|
|
161
|
+
}));
|
|
162
|
+
const subtotalCents = lines.reduce((sum, l) => sum + l.amountCents, 0);
|
|
163
|
+
const totalCents = totals.length === 1 ? totals[0] : null;
|
|
164
|
+
if (totalCents !== null && totalCents !== subtotalCents && totalCents !== withGst(subtotalCents)) {
|
|
165
|
+
const taxable = lines.filter((l) => l.taxable !== false).reduce((sum, l) => sum + l.amountCents, 0);
|
|
166
|
+
if (totalCents !== subtotalCents + Math.round(taxable * 0.1)) findings.push({ code: 'total_differs_from_lines', blocking: false });
|
|
167
|
+
}
|
|
168
|
+
const pick = (field) => members.map((m) => m.fields[field]).find(Boolean) ?? null;
|
|
169
|
+
const job = {
|
|
170
|
+
invoiceNumber: pick('invoiceNumber'),
|
|
171
|
+
caseRef: pick('caseRef'),
|
|
172
|
+
company: first.company,
|
|
173
|
+
accountNumber: pick('accountNumber'),
|
|
174
|
+
doctorName: first.doctorName,
|
|
175
|
+
status: pick('status'),
|
|
176
|
+
jobDate: pick('jobDate'),
|
|
177
|
+
dueDate: pick('dueDate'),
|
|
178
|
+
shippedDate: pick('shippedDate'),
|
|
179
|
+
lines,
|
|
180
|
+
subtotalCents,
|
|
181
|
+
totalCents,
|
|
182
|
+
patientReference: pick('patient'),
|
|
183
|
+
};
|
|
184
|
+
// A job with no Seazona number is keyed by what it is, without the patient.
|
|
185
|
+
let key = group;
|
|
186
|
+
if (group.startsWith('grp:')) {
|
|
187
|
+
const base = `grp:${hashOf({ ...job, patientReference: null }).slice(0, 24)}`;
|
|
188
|
+
const n = (taken.get(base) ?? 0) + 1;
|
|
189
|
+
taken.set(base, n);
|
|
190
|
+
key = n === 1 ? base : `${base}#${n}`;
|
|
191
|
+
}
|
|
192
|
+
jobs.push({ jobKey: key, job, findings });
|
|
193
|
+
for (const m of members) rowKeys.set(m.rowNumber, key);
|
|
194
|
+
}
|
|
195
|
+
return { jobs, rowKeys };
|
|
196
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { guardAlerts } from '../call-guard.js';
|
|
1
2
|
import { createSeazonaClient } from './client.js';
|
|
2
|
-
import { listClients, listWork, readBillableWork } from './feed.js';
|
|
3
|
+
import { listClients, listProducts, listWork, readBillableWork } from './feed.js';
|
|
4
|
+
import { historyJobIssues, historySummary, readHistoryWork } from './history/index.js';
|
|
3
5
|
import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
|
|
6
|
+
import { SEAZONA_CALL_GUARD } from './governor.js';
|
|
7
|
+
import { createSeazonaRouter } from './routes.js';
|
|
4
8
|
import { createSeazonaScheduler } from './scheduler.js';
|
|
5
9
|
import { resetFailedLanes, resetStoppedLanes } from './store.js';
|
|
6
10
|
import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
|
|
@@ -58,6 +62,11 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
58
62
|
bundled: true,
|
|
59
63
|
schema: seazonaSchema,
|
|
60
64
|
setup: SEAZONA_SETUP,
|
|
65
|
+
// The history import's routes (routes.js), under /api/seazona.
|
|
66
|
+
routes: ({ db, personAuth, log }) => createSeazonaRouter({ db: runtime?.db ?? db, personAuth, settings: current().settings, log }),
|
|
67
|
+
// Every Seazona call goes through this call guard, inside the rate
|
|
68
|
+
// governor (governor.js; docs/INTEGRATIONS.md, "Outbound calls").
|
|
69
|
+
callGuard: SEAZONA_CALL_GUARD,
|
|
61
70
|
|
|
62
71
|
// `reconfigured` is true when a person saved the settings or turned
|
|
63
72
|
// Seazona on (issue #101): that clears a halt after a refused API client,
|
|
@@ -102,9 +111,18 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
102
111
|
scheduler: scheduler ? scheduler.status() : null,
|
|
103
112
|
governor: await client.governor.status(),
|
|
104
113
|
...sync,
|
|
114
|
+
history: await historySummary(db),
|
|
105
115
|
};
|
|
106
116
|
},
|
|
107
117
|
|
|
118
|
+
// What only a person can clear, for the Integrations page
|
|
119
|
+
// (server/core/alerts.js): a refused API client halts every call until
|
|
120
|
+
// a person saves the settings. Reads Flow's database only.
|
|
121
|
+
async alerts() {
|
|
122
|
+
if (!runtime?.client.governor) return [];
|
|
123
|
+
return guardAlerts('Seazona', await runtime.client.governor.status(), { fix: 'Check the API client in the Seazona settings, then save them.' });
|
|
124
|
+
},
|
|
125
|
+
|
|
108
126
|
// Billable work since a cursor: complete and shipped orders that
|
|
109
127
|
// changed, with the Seazona invoice that carries each one. Orders
|
|
110
128
|
// deleted in Seazona are never billable; they come back in `withdrawn`
|
|
@@ -113,6 +131,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
113
131
|
return readBillableWork(current().db, { cursor, ...options });
|
|
114
132
|
},
|
|
115
133
|
|
|
134
|
+
// Past work from the history import (docs/SEAZONA.md, "History
|
|
135
|
+
// import"), as ledger jobs marked `historical`. Reads Flow's copy only.
|
|
136
|
+
async pullHistoryJobs(cursor = null, options = {}) {
|
|
137
|
+
return readHistoryWork(current().db, { cursor, ...options });
|
|
138
|
+
},
|
|
139
|
+
|
|
116
140
|
// Seazona clients by id and company name, for the identity map. Reads
|
|
117
141
|
// Flow's copy only.
|
|
118
142
|
async listCustomers() {
|
|
@@ -124,6 +148,18 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
124
148
|
async listWork() {
|
|
125
149
|
return listWork(current().db, { now: now() });
|
|
126
150
|
},
|
|
151
|
+
|
|
152
|
+
// History jobs a person must decide, as issues on their job cards,
|
|
153
|
+
// each with a link to the reconciliation. Reads Flow's copy only.
|
|
154
|
+
async jobIssues({ refs = [] } = {}) {
|
|
155
|
+
return historyJobIssues(current().db, refs);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// Seazona's products, for the price list (server/core/price-list.js).
|
|
159
|
+
// Reads Flow's copy only.
|
|
160
|
+
async listPriceItems() {
|
|
161
|
+
return listProducts(current().db);
|
|
162
|
+
},
|
|
127
163
|
},
|
|
128
164
|
};
|
|
129
165
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
orderListSignature,
|
|
10
10
|
text,
|
|
11
11
|
} from './map.js';
|
|
12
|
+
import { runHistoryBackfill } from './history/backfill.js';
|
|
12
13
|
import { seazonaOrders } from './schema.js';
|
|
13
14
|
import {
|
|
14
15
|
claimLane,
|
|
@@ -38,12 +39,18 @@ import {
|
|
|
38
39
|
// clients changed clients since a cursor, every 30 min
|
|
39
40
|
// products the product list, daily
|
|
40
41
|
// users the lab user list, daily
|
|
42
|
+
// history past orders, a few calls per run, only while a person
|
|
43
|
+
// has started a backfill (history/backfill.js)
|
|
41
44
|
//
|
|
42
45
|
// Issue #12 holds for every order read: a 404 marks the order deleted in
|
|
43
46
|
// Seazona, an empty answer is a failure, each failure is recorded per order
|
|
44
47
|
// and quarantines it after a few in a row, and a lane fails only when most of
|
|
45
48
|
// its reads fail. Errors and /health name each failing order and its status.
|
|
46
49
|
//
|
|
50
|
+
// Issue #125: a list at the row cap is read on, not held: invoices and
|
|
51
|
+
// clients from the last row they answered, orders page by page. A lane holds
|
|
52
|
+
// only where Seazona gives no way on.
|
|
53
|
+
//
|
|
47
54
|
// Issue #101 holds for every lane: invoices and clients quarantine a record
|
|
48
55
|
// after a few failed reads too, so one bad record never holds a cursor. A
|
|
49
56
|
// list call that Seazona refuses (400, 401, 403, 404) means the path or the
|
|
@@ -51,36 +58,47 @@ import {
|
|
|
51
58
|
// only after a person saves the settings or Flow restarts on a new release.
|
|
52
59
|
// Calls the governor stopped (SeazonaCallsStopped) wait out its pause.
|
|
53
60
|
|
|
54
|
-
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users']);
|
|
61
|
+
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history']);
|
|
55
62
|
|
|
56
63
|
const DAY_MS = 24 * 60 * 60_000;
|
|
57
64
|
const MAX_REPORTED = 25;
|
|
58
65
|
const FINAL_STATUS_PATTERN = 'shipped|delivered|cancelled|canceled|void|closed|archived|deleted';
|
|
59
66
|
|
|
60
67
|
class LaneError extends Error {
|
|
61
|
-
constructor(message, { code, details = null, held = false, retryAfterMs = null } = {}) {
|
|
68
|
+
constructor(message, { code, details = null, held = false, retryAfterMs = null, progress = null } = {}) {
|
|
62
69
|
super(message);
|
|
63
70
|
this.code = code;
|
|
64
71
|
this.details = details;
|
|
65
72
|
this.held = held;
|
|
66
73
|
this.retryAfterMs = retryAfterMs;
|
|
74
|
+
// What the lane read before it stopped: { cursor }, kept with the failure.
|
|
75
|
+
this.progress = progress;
|
|
67
76
|
}
|
|
68
77
|
}
|
|
69
78
|
|
|
70
|
-
function listOrFail(list, what
|
|
79
|
+
function listOrFail(list, what) {
|
|
71
80
|
if (!Array.isArray(list)) throw new LaneError(`Seazona ${what} answer was not a list`, { code: 'seazona_list_invalid' });
|
|
72
|
-
// Seazona documents no paging. A list exactly at the cap may be cut off,
|
|
73
|
-
// so hold the cursor rather than trust it.
|
|
74
|
-
if (list.length === sync.listCap) {
|
|
75
|
-
throw new LaneError(`Seazona ${what} answer has exactly ${sync.listCap} rows and may be cut off; the cursor is held`, {
|
|
76
|
-
code: 'seazona_list_at_cap',
|
|
77
|
-
held: true,
|
|
78
|
-
details: { observedCount: list.length, listCap: sync.listCap },
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
81
|
return list;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Issue #125: a list at the cap is read on, page by page or from its last
|
|
85
|
+
// row. The lane holds only where it cannot go on.
|
|
86
|
+
function cannotGoOn(what, sync, reason, progress) {
|
|
87
|
+
return new LaneError(`Seazona ${what} answer is full (${sync.listCap} rows) and cannot be read further (${reason}); the cursor is held`, {
|
|
88
|
+
code: 'seazona_list_at_cap',
|
|
89
|
+
held: true,
|
|
90
|
+
details: { listCap: sync.listCap, reason },
|
|
91
|
+
progress,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Seazona writes lastModified in UTC with no zone suffix.
|
|
96
|
+
function parseUtc(value) {
|
|
97
|
+
const stamp = text(value);
|
|
98
|
+
if (!stamp) return NaN;
|
|
99
|
+
return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(stamp) ? stamp : `${stamp}Z`);
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
// A failure that is about Seazona or the rate limit, not one record: the
|
|
85
103
|
// whole lane stops and waits.
|
|
86
104
|
function isLaneLevel(error) {
|
|
@@ -239,9 +257,67 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
239
257
|
return { summary, detailReads, listCount: items.length, failureDetails, anyFailed, newlyQuarantined, quarantined: skipped.length };
|
|
240
258
|
}
|
|
241
259
|
|
|
242
|
-
|
|
260
|
+
// Where the next changed-list read starts. A run that stopped inside a
|
|
261
|
+
// window keeps the exact start of the next part (`cursor.since`); otherwise
|
|
262
|
+
// the last finished run's start, less the overlap.
|
|
263
|
+
function cursorSince(ctx, fallback) {
|
|
264
|
+
const exact = ctx.state?.cursor?.since;
|
|
265
|
+
if (typeof exact === 'string') return exact;
|
|
243
266
|
const cursorMs = ctx.state?.cursorTime?.getTime();
|
|
244
|
-
return
|
|
267
|
+
return Number.isFinite(cursorMs) ? new Date(cursorMs - ctx.sync.cursorOverlapMs).toISOString() : fallback;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function mergeDetails(total, details) {
|
|
271
|
+
if (!details) return total;
|
|
272
|
+
if (!total) return details;
|
|
273
|
+
return {
|
|
274
|
+
...total,
|
|
275
|
+
attempted: (total.attempted ?? 0) + (details.attempted ?? 0),
|
|
276
|
+
failed: (total.failed ?? 0) + (details.failed ?? 0),
|
|
277
|
+
failures: [...(total.failures ?? []), ...(details.failures ?? [])].slice(0, MAX_REPORTED),
|
|
278
|
+
...(total.quarantinedIds || details.quarantinedIds
|
|
279
|
+
? { quarantinedIds: [...(total.quarantinedIds ?? []), ...(details.quarantinedIds ?? [])].slice(0, MAX_REPORTED) }
|
|
280
|
+
: {}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Invoices and clients: Seazona answers changes since a time in one list,
|
|
285
|
+
// with no paging, cut at the cap. The guide says the invoice list answers
|
|
286
|
+
// changes on or after the time, oldest first, so a full list is one part of
|
|
287
|
+
// the window: the next part starts at its last row (less `stepBackMs`, for a
|
|
288
|
+
// list that answers strictly after). Rows out of order, or a full list inside
|
|
289
|
+
// one second, cannot go on: the lane holds, and keeps where it got to. A run
|
|
290
|
+
// reads at most `listPagesPerRun` parts; the next run goes on from the exact
|
|
291
|
+
// start it stored.
|
|
292
|
+
async function walkChangedList(ctx, { what, firstSince, stepBackMs, list, read }) {
|
|
293
|
+
const { sync, at, state } = ctx;
|
|
294
|
+
const total = { summary: { created: 0, changed: 0, unchanged: 0 }, detailReads: 0, listCount: 0, failureDetails: null, newlyQuarantined: 0, quarantined: 0 };
|
|
295
|
+
let since = firstSince;
|
|
296
|
+
for (let pages = 1; ; pages += 1) {
|
|
297
|
+
const items = listOrFail(await list(since), `${what} list`);
|
|
298
|
+
const result = await read(items);
|
|
299
|
+
addSummary(total.summary, result.summary);
|
|
300
|
+
total.detailReads += result.detailReads;
|
|
301
|
+
total.listCount += result.listCount;
|
|
302
|
+
total.newlyQuarantined += result.newlyQuarantined;
|
|
303
|
+
total.quarantined += result.quarantined;
|
|
304
|
+
total.failureDetails = mergeDetails(total.failureDetails, result.failureDetails);
|
|
305
|
+
const keep = { ...total, pages, since: firstSince || null };
|
|
306
|
+
// A failed read keeps this part's start, so the next run reads it again.
|
|
307
|
+
if (result.anyFailed) return { ...keep, anyFailed: true, cursorTime: state?.cursorTime ?? null, cursor: { since } };
|
|
308
|
+
if (items.length < sync.listCap) return { ...keep, cursorTime: at, cursor: {} };
|
|
309
|
+
const sinceMs = since ? parseUtc(since) : -Infinity;
|
|
310
|
+
let lastMs = -Infinity;
|
|
311
|
+
for (const item of items) {
|
|
312
|
+
const ms = parseUtc(item?.lastModified);
|
|
313
|
+
if (!Number.isFinite(ms) || ms < lastMs) throw cannotGoOn(`${what} list`, sync, 'rows are not oldest first', { cursor: { since } });
|
|
314
|
+
lastMs = ms;
|
|
315
|
+
}
|
|
316
|
+
const next = lastMs - stepBackMs;
|
|
317
|
+
if (!(next > sinceMs)) throw cannotGoOn(`${what} list`, sync, 'one second holds more than the cap', { cursor: { since } });
|
|
318
|
+
since = new Date(next).toISOString();
|
|
319
|
+
if (pages >= sync.listPagesPerRun) return { ...keep, cursorTime: state?.cursorTime ?? null, cursor: { since } };
|
|
320
|
+
}
|
|
245
321
|
}
|
|
246
322
|
|
|
247
323
|
const floorUtcDay = (ms) => {
|
|
@@ -255,24 +331,43 @@ export function formatOrderedDay(ms) {
|
|
|
255
331
|
return new Date(floorUtcDay(ms)).toISOString().replace(/\.\d{3}Z$/, '');
|
|
256
332
|
}
|
|
257
333
|
|
|
334
|
+
// The guide's largest order page.
|
|
335
|
+
const ORDER_PAGE_MAX = 500;
|
|
336
|
+
|
|
337
|
+
// True when an order row was placed on a later day than `dayMs` (the start
|
|
338
|
+
// of a UTC day). `ordered` is the lab's local time with no zone, written
|
|
339
|
+
// yyyy-MM-ddTHH:mm:ss or dd/MM/yyyy; this compares calendar days only, so a
|
|
340
|
+
// lab ahead of UTC never ends a day early. Unknown dates are not after.
|
|
341
|
+
function placedAfter(item, dayMs) {
|
|
342
|
+
const raw = text(item?.ordered);
|
|
343
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
|
|
344
|
+
const local = /^(\d{2})\/(\d{2})\/(\d{4})/.exec(raw);
|
|
345
|
+
const placed = iso ? Date.UTC(+iso[1], +iso[2] - 1, +iso[3]) : local ? Date.UTC(+local[3], +local[2] - 1, +local[1]) : NaN;
|
|
346
|
+
return Number.isFinite(placed) && placed > dayMs;
|
|
347
|
+
}
|
|
348
|
+
|
|
258
349
|
const laneRuns = {
|
|
259
350
|
async invoices(ctx) {
|
|
260
351
|
const { client, sync, at } = ctx;
|
|
261
|
-
const
|
|
262
|
-
const items = listOrFail(await client.get('invoices/', { lastModified: since }), 'invoice list', sync);
|
|
352
|
+
const firstSince = cursorSince(ctx, new Date(floorUtcDay(at.getTime()) - sync.invoiceLookbackDays * DAY_MS).toISOString());
|
|
263
353
|
const mapOptions = { keepPatientReference: sync.keepPatientReference };
|
|
264
|
-
|
|
265
|
-
mirror: 'invoices',
|
|
354
|
+
return walkChangedList(ctx, {
|
|
266
355
|
what: 'invoice',
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
356
|
+
firstSince,
|
|
357
|
+
// The guide: on or after lastModified (inclusive).
|
|
358
|
+
stepBackMs: 0,
|
|
359
|
+
list: (since) => client.get('invoices/', { lastModified: since }),
|
|
360
|
+
read: (items) => syncListWithDetail(ctx, {
|
|
361
|
+
mirror: 'invoices',
|
|
362
|
+
what: 'invoice',
|
|
363
|
+
items,
|
|
364
|
+
keyOf: (item) => text(item?.invoiceNumber),
|
|
365
|
+
idOf: (item) => text(item?.id),
|
|
366
|
+
signatureOf: invoiceListSignature,
|
|
367
|
+
detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
|
|
368
|
+
map: (item, detail) => mapInvoice(item, detail, mapOptions),
|
|
369
|
+
}),
|
|
273
370
|
});
|
|
274
|
-
// A failed invoice read keeps the cursor, so the next run reads it again.
|
|
275
|
-
return { ...result, cursorTime: result.anyFailed ? ctx.state?.cursorTime ?? null : at, since };
|
|
276
371
|
},
|
|
277
372
|
|
|
278
373
|
async orders_recent(ctx) {
|
|
@@ -280,15 +375,44 @@ const laneRuns = {
|
|
|
280
375
|
const nowMs = at.getTime();
|
|
281
376
|
const lookbackStart = floorUtcDay(nowMs - sync.orderLookbackDays * DAY_MS);
|
|
282
377
|
const doneThrough = Date.parse(ctx.state?.cursor?.completedThrough ?? '');
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
378
|
+
const partial = ctx.state?.cursor?.partial;
|
|
379
|
+
const partialDay = Date.parse(partial?.day ?? '');
|
|
380
|
+
// Go on inside a day a run did not finish; otherwise re-read the last
|
|
381
|
+
// finished day as well, so late orders on it are seen.
|
|
382
|
+
const resumeInside = Number.isFinite(partialDay) && partialDay >= lookbackStart;
|
|
383
|
+
const resume = resumeInside ? partialDay : Number.isFinite(doneThrough) ? Math.max(lookbackStart, doneThrough) : lookbackStart;
|
|
287
384
|
|
|
385
|
+
// Issue #125: the list is paged (page, pageSize). A full page goes on to
|
|
386
|
+
// the next page; a short page, or a page past the day, ends the day. The
|
|
387
|
+
// guide says the list answers orders on or after `ordered`; a list that
|
|
388
|
+
// answers one day works the same way.
|
|
389
|
+
const pageSize = Math.min(ORDER_PAGE_MAX, sync.listCap);
|
|
288
390
|
const byId = new Map();
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
391
|
+
const days = [];
|
|
392
|
+
let calls = 0;
|
|
393
|
+
let lastDone = null;
|
|
394
|
+
let stoppedAt = null;
|
|
395
|
+
for (let day = floorUtcDay(resume); day <= floorUtcDay(nowMs) && days.length < sync.orderDaysPerRun && !stoppedAt; day += DAY_MS) {
|
|
396
|
+
const label = `order list for ${formatOrderedDay(day)}`;
|
|
397
|
+
let page = resumeInside && day === floorUtcDay(resume) ? Math.max(1, Number(partial.page) || 1) : 1;
|
|
398
|
+
let previous = null;
|
|
399
|
+
for (;;) {
|
|
400
|
+
if (calls >= sync.listPagesPerRun) {
|
|
401
|
+
stoppedAt = { day, page };
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
calls += 1;
|
|
405
|
+
if (!days.includes(day)) days.push(day);
|
|
406
|
+
const list = listOrFail(await client.get('orders/', { ordered: formatOrderedDay(day), page, pageSize }), label);
|
|
407
|
+
const ids = list.map((item) => text(item?.id)).filter(Boolean);
|
|
408
|
+
// A Seazona that does not page answers the same rows again.
|
|
409
|
+
if (previous && ids.length && ids.every((id) => previous.has(id))) throw cannotGoOn(label, sync, 'the next page answers the same orders');
|
|
410
|
+
for (const item of list) if (text(item?.id)) byId.set(text(item.id), item);
|
|
411
|
+
if (list.length < pageSize || placedAfter(list[list.length - 1], day + DAY_MS)) break;
|
|
412
|
+
previous = new Set(ids);
|
|
413
|
+
page += 1;
|
|
414
|
+
}
|
|
415
|
+
if (!stoppedAt) lastDone = day;
|
|
292
416
|
}
|
|
293
417
|
const known = await readKnown(db, 'orders', [...byId.keys()]);
|
|
294
418
|
const summary = { created: 0, changed: 0, unchanged: 0 };
|
|
@@ -312,16 +436,20 @@ const laneRuns = {
|
|
|
312
436
|
await touchSynced(db, 'orders', unchanged, { at });
|
|
313
437
|
summary.unchanged += unchanged.length;
|
|
314
438
|
const failureDetails = judgeReads('recent order reads', detailReads, failures);
|
|
315
|
-
const
|
|
439
|
+
const through = lastDone ?? (Number.isFinite(doneThrough) ? doneThrough : null);
|
|
316
440
|
return {
|
|
317
441
|
summary,
|
|
318
442
|
detailReads,
|
|
319
443
|
listCount: byId.size,
|
|
444
|
+
listCalls: calls,
|
|
320
445
|
days: days.map(formatOrderedDay),
|
|
321
446
|
deletedIds: deleted,
|
|
322
447
|
failureDetails,
|
|
323
|
-
cursorTime: new Date(
|
|
324
|
-
cursor: {
|
|
448
|
+
cursorTime: through === null ? ctx.state?.cursorTime ?? null : new Date(through),
|
|
449
|
+
cursor: {
|
|
450
|
+
...(through === null ? {} : { completedThrough: new Date(through).toISOString() }),
|
|
451
|
+
...(stoppedAt ? { partial: { day: new Date(stoppedAt.day).toISOString(), page: stoppedAt.page } } : {}),
|
|
452
|
+
},
|
|
325
453
|
};
|
|
326
454
|
},
|
|
327
455
|
|
|
@@ -356,33 +484,40 @@ const laneRuns = {
|
|
|
356
484
|
},
|
|
357
485
|
|
|
358
486
|
async clients(ctx) {
|
|
359
|
-
const { client
|
|
487
|
+
const { client } = ctx;
|
|
360
488
|
// The first run reads the whole list; later runs read changes only.
|
|
361
489
|
// Seazona answers the whole list only to an empty `?lastModified=`; a
|
|
362
490
|
// bare path is a 404.
|
|
363
|
-
|
|
364
|
-
const reply = since ? await client.get('clients/', { lastModified: since }) : await client.get('clients/?lastModified=');
|
|
365
|
-
const items = listOrFail(reply, 'client list', sync);
|
|
366
|
-
const result = await syncListWithDetail(ctx, {
|
|
367
|
-
mirror: 'clients',
|
|
491
|
+
return walkChangedList(ctx, {
|
|
368
492
|
what: 'client',
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
493
|
+
firstSince: cursorSince(ctx, ''),
|
|
494
|
+
// The guide says "modified after": step back a second so a row in the
|
|
495
|
+
// last row's second is not lost.
|
|
496
|
+
stepBackMs: 1000,
|
|
497
|
+
list: (since) => (since ? client.get('clients/', { lastModified: since }) : client.get('clients/?lastModified=')),
|
|
498
|
+
read: (items) => syncListWithDetail(ctx, {
|
|
499
|
+
mirror: 'clients',
|
|
500
|
+
what: 'client',
|
|
501
|
+
items,
|
|
502
|
+
keyOf: (item) => text(item?.id),
|
|
503
|
+
idOf: (item) => text(item?.id),
|
|
504
|
+
signatureOf: clientListSignature,
|
|
505
|
+
detailPath: (id) => `clients/${encodeURIComponent(id)}`,
|
|
506
|
+
map: (item, detail) => mapClient(item, detail),
|
|
507
|
+
}),
|
|
375
508
|
});
|
|
376
|
-
return { ...result, cursorTime: result.anyFailed ? ctx.state?.cursorTime ?? null : at, since: since || null };
|
|
377
509
|
},
|
|
378
510
|
|
|
511
|
+
history: (ctx) => runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
|
|
379
512
|
products: (ctx) => syncReference(ctx, 'products'),
|
|
380
513
|
users: (ctx) => syncReference(ctx, 'users'),
|
|
381
514
|
};
|
|
382
515
|
|
|
383
516
|
async function syncReference(ctx, resource) {
|
|
384
517
|
const { db, client, sync, at } = ctx;
|
|
385
|
-
const list = listOrFail(await client.get(`${resource}/`), `${resource} list
|
|
518
|
+
const list = listOrFail(await client.get(`${resource}/`), `${resource} list`);
|
|
519
|
+
// The whole list in one answer, with no paging and no way to read on.
|
|
520
|
+
if (list.length === sync.listCap) throw cannotGoOn(`${resource} list`, sync, 'the list has no paging');
|
|
386
521
|
const records = list.map((item) => mapReference(resource, item)).filter((record) => record.id);
|
|
387
522
|
const { stored } = await upsertReference(db, resource, records, { at });
|
|
388
523
|
return { listCount: list.length, stored, cursorTime: at };
|
|
@@ -439,6 +574,7 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
|
|
|
439
574
|
status,
|
|
440
575
|
lastAttemptAt: at,
|
|
441
576
|
nextRunAt: delayMs === null ? null : new Date(Number(now()) + delayMs),
|
|
577
|
+
...(error?.progress?.cursor ? { cursor: error.progress.cursor } : {}),
|
|
442
578
|
failureCount: (state?.failureCount ?? 0) + 1,
|
|
443
579
|
lastError: message,
|
|
444
580
|
failureDetails: error?.details ?? (Number.isInteger(error?.statusCode) ? { httpStatus: error.statusCode, path: error.path ?? null } : null),
|