@arcforgelabs/flow 2026.10.29 → 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.
Files changed (76) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +32 -1
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoicing-rules.js +12 -0
  12. package/server/core/job-board.js +25 -3
  13. package/server/core/line-mapping.js +36 -10
  14. package/server/core/pipeline.js +84 -6
  15. package/server/core/price-list.js +348 -0
  16. package/server/core/queue.js +22 -0
  17. package/server/core/sending.js +16 -4
  18. package/server/db/commands.js +42 -0
  19. package/server/db/doctor.js +168 -0
  20. package/server/db/migrations/0014_price_list.sql +34 -0
  21. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  22. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  23. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  24. package/server/db/migrations/meta/_journal.json +14 -0
  25. package/server/db/schema.js +44 -0
  26. package/server/flow-server.js +20 -1
  27. package/server/index.js +2 -1
  28. package/server/integrations/call-guard.js +362 -0
  29. package/server/integrations/port.js +23 -2
  30. package/server/integrations/seazona/client.js +6 -0
  31. package/server/integrations/seazona/feed.js +23 -1
  32. package/server/integrations/seazona/governor.js +74 -21
  33. package/server/integrations/seazona/history/backfill.js +208 -0
  34. package/server/integrations/seazona/history/csv.js +65 -0
  35. package/server/integrations/seazona/history/index.js +584 -0
  36. package/server/integrations/seazona/history/read.js +196 -0
  37. package/server/integrations/seazona/index.js +51 -2
  38. package/server/integrations/seazona/lanes.js +251 -62
  39. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  40. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  41. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  42. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  43. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  44. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  45. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  46. package/server/integrations/seazona/routes.js +145 -0
  47. package/server/integrations/seazona/scheduler.js +37 -1
  48. package/server/integrations/seazona/schema.js +116 -0
  49. package/server/integrations/seazona/settings.js +24 -3
  50. package/server/integrations/seazona/store.js +74 -0
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +5 -0
  53. package/server/integrations/xero/client.js +27 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +125 -16
  56. package/server/integrations/xero/index.js +36 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  60. package/server/integrations/xero/mirror.js +11 -2
  61. package/server/integrations/xero/oauth.js +4 -1
  62. package/server/integrations/xero/reconcile.js +173 -0
  63. package/server/integrations/xero/routes.js +35 -0
  64. package/server/integrations/xero/runtime.js +45 -4
  65. package/server/release-channel.js +3 -3
  66. package/server/ui/alerts.js +77 -0
  67. package/server/ui/app-page.js +1 -1
  68. package/server/ui/app.js +48 -14
  69. package/server/ui/clerk-appearance.js +42 -9
  70. package/server/ui/flow.css +31 -2
  71. package/server/ui/invoicing-settings.js +1 -1
  72. package/server/ui/invoicing.js +121 -8
  73. package/server/ui/line-mappings.js +3 -2
  74. package/server/ui/monitoring.js +14 -0
  75. package/server/ui/price-list.js +247 -0
  76. package/server/ui/routes.js +1 -1
@@ -0,0 +1,584 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { and, asc, desc, eq, gt, inArray, sql } from 'drizzle-orm';
3
+ import { FlowError } from '../../../core/errors.js';
4
+ import { listClients } from '../feed.js';
5
+ import { hashOf, parseSeazonaDate, text } from '../map.js';
6
+ import {
7
+ seazonaClients,
8
+ seazonaHistoryBatches,
9
+ seazonaHistoryBatchJobs,
10
+ seazonaHistoryJobs,
11
+ seazonaHistoryRows,
12
+ seazonaInvoices,
13
+ seazonaOrders,
14
+ } from '../schema.js';
15
+ import { CsvError, parseCsv } from './csv.js';
16
+ import { detectColumns, groupJobs, INVOICE_LINE_COLUMNS, readRow } from './read.js';
17
+
18
+ // The history import (docs/SEAZONA.md, "History import"). A person uploads
19
+ // Seazona's CSV export; Flow stages it, reconciles each job with what it
20
+ // already holds, lets a person decide the jobs it cannot settle, and applies
21
+ // the batch into `seazona_history_jobs`. It reads and writes Flow's database
22
+ // only: no Seazona or Xero call, so no call guard applies. Nothing here
23
+ // overwrites the API copy: a difference is a finding, never a change.
24
+
25
+ export const PROFILE = 'invoice_lines';
26
+ export const MAX_ROWS = 200_000;
27
+ export const OUTCOMES = ['new', 'same', 'changed', 'needs_person'];
28
+ export const DECISIONS = ['import', 'skip'];
29
+ const CHUNK = 500;
30
+
31
+ const invalid = (message, details) => Object.assign(new FlowError('invalid', message, details), { status: 400 });
32
+ const conflict = (message) => Object.assign(new FlowError('conflict', message), { status: 409 });
33
+
34
+ function chunks(list, size = CHUNK) {
35
+ const out = [];
36
+ for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
37
+ return out;
38
+ }
39
+
40
+ const normaliseName = (name) => text(name).toLowerCase()
41
+ .replace(/&/g, ' and ')
42
+ .replace(/[^a-z0-9]+/g, ' ')
43
+ .replace(/\b(pty|ltd|limited|proprietary)\b/g, ' ')
44
+ .replace(/\s+/g, ' ')
45
+ .trim();
46
+
47
+ async function requireBatch(db, batchId) {
48
+ const id = Number(batchId);
49
+ if (!Number.isSafeInteger(id) || id < 1) throw invalid('batch id must be a number');
50
+ const [batch] = await db.select().from(seazonaHistoryBatches).where(eq(seazonaHistoryBatches.id, id));
51
+ if (!batch) throw Object.assign(new FlowError('not_found', 'import batch not found'), { status: 404 });
52
+ return batch;
53
+ }
54
+
55
+ // Reads the file into a staged batch and reconciles it. The same file staged
56
+ // before (and not cancelled) answers that batch instead. `columns` maps a
57
+ // Flow field to a header when the guess is wrong (field -> header, or null).
58
+ export async function stageBatch(db, { csv, fileName = null, columns: override = {}, createdBy, keepPatientReference = false, maxRows = MAX_ROWS, now = Date.now }) {
59
+ if (typeof csv !== 'string' || !csv.trim()) throw invalid('The upload is empty');
60
+ if (!createdBy) throw invalid('createdBy is required');
61
+ const fileSha256 = createHash('sha256').update(csv).digest('hex');
62
+ const [earlier] = await db.select().from(seazonaHistoryBatches)
63
+ .where(and(eq(seazonaHistoryBatches.fileSha256, fileSha256), sql`${seazonaHistoryBatches.status} <> 'cancelled'`))
64
+ .orderBy(desc(seazonaHistoryBatches.id)).limit(1);
65
+ if (earlier) return { ...(await summarise(db, earlier)), duplicate: true };
66
+
67
+ let parsed;
68
+ try {
69
+ parsed = parseCsv(csv);
70
+ } catch (error) {
71
+ if (error instanceof CsvError) throw invalid(error.message, { line: error.line });
72
+ throw error;
73
+ }
74
+ if (parsed.rows.length > maxRows) throw invalid(`The file has ${parsed.rows.length} rows; split it into files of at most ${maxRows}`);
75
+ let detected;
76
+ try {
77
+ detected = detectColumns(parsed.headers, override);
78
+ } catch (error) {
79
+ throw invalid(error.message);
80
+ }
81
+ if (detected.missing.length) {
82
+ throw invalid(`The file is missing columns Flow needs: ${detected.missing.join(', ')}`, { missing: detected.missing, headers: parsed.headers, columns: detected.columns });
83
+ }
84
+
85
+ const rows = parsed.rows.map((row, i) => ({ rowNumber: i + 1, line: row.line, ...readRow(row.cells, parsed.headers, detected.columns) }));
86
+ const { jobs, rowKeys } = groupJobs(rows);
87
+ const at = new Date(Number(now()));
88
+
89
+ const batchId = await db.transaction(async (tx) => {
90
+ const [batch] = await tx.insert(seazonaHistoryBatches).values({
91
+ fileName: fileName ? String(fileName).slice(0, 200) : null,
92
+ fileSha256,
93
+ profile: PROFILE,
94
+ columns: detected.columns,
95
+ ignoredColumns: detected.ignored,
96
+ status: 'staged',
97
+ counts: {},
98
+ createdBy: String(createdBy),
99
+ createdAt: at,
100
+ }).returning({ id: seazonaHistoryBatches.id });
101
+ // The patient field groups rows; it is stored only when the installation
102
+ // keeps a patient reference (SEAZONA_KEEP_PATIENT_REFERENCE).
103
+ const keep = (value) => (keepPatientReference ? value : null);
104
+ for (const part of chunks(rows)) {
105
+ await tx.insert(seazonaHistoryRows).values(part.map((row) => ({
106
+ batchId: batch.id,
107
+ rowNumber: row.rowNumber,
108
+ jobKey: rowKeys.get(row.rowNumber) ?? null,
109
+ fields: { ...row.fields, patient: keep(row.fields.patient), line: row.line },
110
+ problems: row.problems,
111
+ })));
112
+ }
113
+ for (const part of chunks(jobs)) {
114
+ await tx.insert(seazonaHistoryBatchJobs).values(part.map(({ jobKey, job, findings }) => {
115
+ const stored = { ...job, patientReference: keep(job.patientReference) };
116
+ return { batchId: batch.id, jobKey, job: { ...stored, fileFindings: findings }, recordHash: hashOf(stored) };
117
+ }));
118
+ }
119
+ return batch.id;
120
+ });
121
+ return reconcileBatch(db, batchId, { now });
122
+ }
123
+
124
+ // Flow's copies that each job could be: the API mirror by number, else by
125
+ // practice, doctor, date and amount; and the Seazona client by account number
126
+ // or practice name.
127
+ async function loadEvidence(db, jobs) {
128
+ const numbers = [...new Set(jobs.map((j) => j.job.invoiceNumber).filter(Boolean))];
129
+ const cases = [...new Set(jobs.map((j) => j.job.caseRef).filter(Boolean))];
130
+ const companies = [...new Set(jobs.filter((j) => !j.job.invoiceNumber && !j.job.caseRef).map((j) => text(j.job.company)))].filter(Boolean);
131
+ const invoices = new Map();
132
+ for (const part of chunks(numbers)) {
133
+ for (const row of await db.select({ invoiceNumber: seazonaInvoices.invoiceNumber, totalCents: seazonaInvoices.totalCents, subtotalCents: seazonaInvoices.subtotalCents })
134
+ .from(seazonaInvoices).where(inArray(seazonaInvoices.invoiceNumber, part))) invoices.set(row.invoiceNumber, row);
135
+ }
136
+ const ordersByInvoice = new Map();
137
+ const ordersById = new Map();
138
+ const orderColumns = { id: seazonaOrders.id, invoiceNumber: seazonaOrders.invoiceNumber, company: seazonaOrders.company, doctorName: seazonaOrders.doctorName, orderedText: seazonaOrders.orderedText, dueText: seazonaOrders.dueText, subtotalCents: seazonaOrders.subtotalCents };
139
+ for (const part of chunks(numbers)) {
140
+ for (const row of await db.select(orderColumns).from(seazonaOrders).where(inArray(seazonaOrders.invoiceNumber, part))) {
141
+ if (!ordersByInvoice.has(row.invoiceNumber)) ordersByInvoice.set(row.invoiceNumber, []);
142
+ ordersByInvoice.get(row.invoiceNumber).push(row);
143
+ }
144
+ }
145
+ for (const part of chunks(cases)) {
146
+ for (const row of await db.select(orderColumns).from(seazonaOrders).where(inArray(seazonaOrders.id, part))) ordersById.set(row.id, row);
147
+ }
148
+ const ordersByCompany = new Map();
149
+ for (const part of chunks(companies.map((c) => c.toLowerCase()))) {
150
+ for (const row of await db.select(orderColumns).from(seazonaOrders).where(inArray(sql`lower(trim(${seazonaOrders.company}))`, part))) {
151
+ const key = normaliseName(row.company);
152
+ if (!ordersByCompany.has(key)) ordersByCompany.set(key, []);
153
+ ordersByCompany.get(key).push(row);
154
+ }
155
+ }
156
+ const clients = await listClients(db);
157
+ const clientsByName = new Map();
158
+ for (const c of clients) {
159
+ const key = normaliseName(c.name);
160
+ if (!clientsByName.has(key)) clientsByName.set(key, new Set());
161
+ clientsByName.get(key).add(c.id);
162
+ }
163
+ const accounts = new Map();
164
+ for (const row of await db.select({ id: seazonaClients.id, accountNumber: seazonaClients.accountNumber }).from(seazonaClients)) {
165
+ if (row.accountNumber) accounts.set(text(row.accountNumber).toLowerCase(), row.id);
166
+ }
167
+ const history = new Map();
168
+ for (const part of chunks(jobs.map((j) => j.jobKey))) {
169
+ for (const row of await db.select({ jobKey: seazonaHistoryJobs.jobKey, recordHash: seazonaHistoryJobs.recordHash })
170
+ .from(seazonaHistoryJobs).where(inArray(seazonaHistoryJobs.jobKey, part))) history.set(row.jobKey, row.recordHash);
171
+ }
172
+ return { invoices, ordersByInvoice, ordersById, ordersByCompany, clientsByName, accounts, history };
173
+ }
174
+
175
+ // One job against the evidence. Answers the batch-job columns reconciliation
176
+ // owns: outcome, findings, mirror references and client.
177
+ export function reconcileJob({ jobKey, job, recordHash }, evidence) {
178
+ const findings = [...(job.fileFindings ?? [])];
179
+ let mirrorOrderId = null;
180
+ let mirrorInvoiceNumber = null;
181
+ let clientId = null;
182
+
183
+ if (job.invoiceNumber || job.caseRef) {
184
+ const invoice = job.invoiceNumber ? evidence.invoices.get(job.invoiceNumber) : null;
185
+ const orders = job.caseRef
186
+ ? [evidence.ordersById.get(job.caseRef)].filter(Boolean)
187
+ : evidence.ordersByInvoice.get(job.invoiceNumber) ?? [];
188
+ if (invoice) mirrorInvoiceNumber = invoice.invoiceNumber;
189
+ if (orders.length === 1) mirrorOrderId = orders[0].id;
190
+ else if (orders.length > 1) findings.push({ code: 'invoice_has_several_orders', blocking: false });
191
+ const theirTotal = invoice?.totalCents ?? (orders.length === 1 ? orders[0].subtotalCents : null);
192
+ if (theirTotal !== null && theirTotal !== undefined && ![job.totalCents, job.subtotalCents].includes(theirTotal)) {
193
+ findings.push({ code: 'differs_from_api_copy', blocking: false });
194
+ }
195
+ } else {
196
+ const day = job.jobDate ?? job.dueDate ?? job.shippedDate;
197
+ const candidates = (evidence.ordersByCompany.get(normaliseName(job.company)) ?? []).filter((o) => (
198
+ normaliseName(o.doctorName) === normaliseName(job.doctorName)
199
+ && o.subtotalCents === job.subtotalCents
200
+ && [parseSeazonaDate(o.dueText), parseSeazonaDate(o.orderedText)].includes(day)
201
+ ));
202
+ if (candidates.length === 1) {
203
+ mirrorOrderId = candidates[0].id;
204
+ mirrorInvoiceNumber = candidates[0].invoiceNumber ?? null;
205
+ } else if (candidates.length > 1) {
206
+ findings.push({ code: 'api_copy_ambiguous', blocking: true });
207
+ }
208
+ }
209
+
210
+ const byAccount = job.accountNumber ? evidence.accounts.get(text(job.accountNumber).toLowerCase()) : null;
211
+ const byName = [...(evidence.clientsByName.get(normaliseName(job.company)) ?? [])];
212
+ if (byAccount) clientId = byAccount;
213
+ else if (byName.length === 1) [clientId] = byName;
214
+ else if (byName.length > 1) findings.push({ code: 'client_ambiguous', blocking: true });
215
+ else findings.push({ code: 'client_unknown', blocking: false });
216
+
217
+ const before = evidence.history.get(jobKey);
218
+ let outcome;
219
+ if (findings.some((f) => f.blocking)) outcome = 'needs_person';
220
+ else if (before === recordHash) outcome = 'same';
221
+ else if (before) {
222
+ findings.push({ code: 'differs_from_earlier_import', blocking: true });
223
+ outcome = 'changed';
224
+ } else outcome = 'new';
225
+ return { outcome, findings, mirrorOrderId, mirrorInvoiceNumber, clientId };
226
+ }
227
+
228
+ // Reconciles every job of a staged or reconciled batch with what Flow holds
229
+ // now. A person's decisions stay.
230
+ export async function reconcileBatch(db, batchId, { now = Date.now } = {}) {
231
+ const batch = await requireBatch(db, batchId);
232
+ if (!['staged', 'reconciled'].includes(batch.status)) throw conflict(`The batch is ${batch.status}`);
233
+ const jobs = await db.select().from(seazonaHistoryBatchJobs).where(eq(seazonaHistoryBatchJobs.batchId, batch.id));
234
+ const evidence = await loadEvidence(db, jobs);
235
+ await db.transaction(async (tx) => {
236
+ for (const job of jobs) {
237
+ const result = reconcileJob(job, evidence);
238
+ // A client a person picked stays.
239
+ if (job.decision !== null && job.clientId) result.clientId = job.clientId;
240
+ await tx.update(seazonaHistoryBatchJobs).set(result)
241
+ .where(and(eq(seazonaHistoryBatchJobs.batchId, batch.id), eq(seazonaHistoryBatchJobs.jobKey, job.jobKey)));
242
+ }
243
+ await tx.update(seazonaHistoryBatches)
244
+ .set({ status: 'reconciled', reconciledAt: new Date(Number(now())), counts: await countBatch(tx, batch.id) })
245
+ .where(eq(seazonaHistoryBatches.id, batch.id));
246
+ });
247
+ return summarise(db, await requireBatch(db, batch.id));
248
+ }
249
+
250
+ // Whether apply writes the job: a person's choice, else only a new job.
251
+ const writes = (job) => job.decision === 'import' || (job.decision === null && job.outcome === 'new');
252
+
253
+ async function countBatch(db, batchId) {
254
+ const [rows] = await db.select({
255
+ rows: sql`count(*)::int`,
256
+ rowsWithProblems: sql`count(*) filter (where jsonb_array_length(${seazonaHistoryRows.problems}) > 0)::int`,
257
+ }).from(seazonaHistoryRows).where(eq(seazonaHistoryRows.batchId, batchId));
258
+ const jobs = await db.select({ outcome: seazonaHistoryBatchJobs.outcome, decision: seazonaHistoryBatchJobs.decision, n: sql`count(*)::int` })
259
+ .from(seazonaHistoryBatchJobs).where(eq(seazonaHistoryBatchJobs.batchId, batchId))
260
+ .groupBy(seazonaHistoryBatchJobs.outcome, seazonaHistoryBatchJobs.decision);
261
+ const counts = { rows: rows.rows, rowsWithProblems: rows.rowsWithProblems, jobs: 0, willImport: 0, waitingForPerson: 0, skipped: 0 };
262
+ for (const o of OUTCOMES) counts[o] = 0;
263
+ for (const g of jobs) {
264
+ counts.jobs += g.n;
265
+ if (g.outcome) counts[g.outcome] += g.n;
266
+ if (writes(g)) counts.willImport += g.n;
267
+ else if (g.decision === 'skip' || g.outcome === 'same') counts.skipped += g.n;
268
+ else counts.waitingForPerson += g.n;
269
+ }
270
+ return counts;
271
+ }
272
+
273
+ async function summarise(db, batch) {
274
+ return {
275
+ id: batch.id,
276
+ fileName: batch.fileName,
277
+ profile: batch.profile,
278
+ status: batch.status,
279
+ columns: batch.columns,
280
+ ignoredColumns: batch.ignoredColumns,
281
+ counts: batch.counts && Object.keys(batch.counts).length ? batch.counts : await countBatch(db, batch.id),
282
+ createdBy: batch.createdBy,
283
+ createdAt: batch.createdAt?.toISOString() ?? null,
284
+ reconciledAt: batch.reconciledAt?.toISOString() ?? null,
285
+ appliedAt: batch.appliedAt?.toISOString() ?? null,
286
+ appliedBy: batch.appliedBy,
287
+ };
288
+ }
289
+
290
+ export async function listBatches(db, { limit = 50 } = {}) {
291
+ const rows = await db.select().from(seazonaHistoryBatches).orderBy(desc(seazonaHistoryBatches.id)).limit(Math.min(200, limit));
292
+ return Promise.all(rows.map((b) => summarise(db, b)));
293
+ }
294
+
295
+ // A batch with one page of its jobs (filter by outcome, or `waiting` for the
296
+ // jobs a person must decide) and the rows Flow could not read.
297
+ export async function readBatch(db, batchId, { outcome = null, limit = 100, offset = 0 } = {}) {
298
+ const batch = await requireBatch(db, batchId);
299
+ const filters = [eq(seazonaHistoryBatchJobs.batchId, batch.id)];
300
+ if (outcome === 'waiting') filters.push(sql`${seazonaHistoryBatchJobs.decision} is null and ${seazonaHistoryBatchJobs.outcome} in ('needs_person', 'changed')`);
301
+ else if (outcome) {
302
+ if (!OUTCOMES.includes(outcome)) throw invalid(`outcome must be one of ${[...OUTCOMES, 'waiting'].join(', ')}`);
303
+ filters.push(eq(seazonaHistoryBatchJobs.outcome, outcome));
304
+ }
305
+ const pageSize = Math.max(1, Math.min(500, Number(limit) || 100));
306
+ const jobs = await db.select().from(seazonaHistoryBatchJobs).where(and(...filters))
307
+ .orderBy(asc(seazonaHistoryBatchJobs.jobKey)).limit(pageSize).offset(Math.max(0, Number(offset) || 0));
308
+ const problemRows = await db.select().from(seazonaHistoryRows)
309
+ .where(and(eq(seazonaHistoryRows.batchId, batch.id), sql`jsonb_array_length(${seazonaHistoryRows.problems}) > 0`))
310
+ .orderBy(asc(seazonaHistoryRows.rowNumber)).limit(200);
311
+ return {
312
+ ...(await summarise(db, batch)),
313
+ jobs: jobs.map((j) => ({
314
+ jobKey: j.jobKey,
315
+ outcome: j.outcome,
316
+ findings: j.findings,
317
+ decision: j.decision,
318
+ willImport: writes(j),
319
+ mirrorOrderId: j.mirrorOrderId,
320
+ mirrorInvoiceNumber: j.mirrorInvoiceNumber,
321
+ clientId: j.clientId,
322
+ job: { ...j.job, fileFindings: undefined },
323
+ })),
324
+ problemRows: problemRows.map((r) => ({ rowNumber: r.rowNumber, line: r.fields.line ?? null, problems: r.problems })),
325
+ };
326
+ }
327
+
328
+ // A person's choices: { jobKey, decision: 'import' | 'skip' | null, clientId? }.
329
+ // A client id picks the Seazona client when the name matched several.
330
+ export async function decideJobs(db, batchId, decisions, { by, now = Date.now }) {
331
+ const batch = await requireBatch(db, batchId);
332
+ if (!['reconciled', 'running'].includes(batch.status)) throw conflict(`The batch is ${batch.status}`);
333
+ if (!Array.isArray(decisions) || !decisions.length || decisions.length > 5000) throw invalid('decisions must list 1 to 5000 jobs');
334
+ const at = new Date(Number(now()));
335
+ let changed = 0;
336
+ await db.transaction(async (tx) => {
337
+ for (const d of decisions) {
338
+ if (!d || typeof d.jobKey !== 'string') throw invalid('each decision needs a jobKey');
339
+ if (d.decision !== null && !DECISIONS.includes(d.decision)) throw invalid(`decision must be ${DECISIONS.join(', ')} or null`);
340
+ const patch = { decision: d.decision, decidedBy: d.decision === null ? null : String(by), decidedAt: d.decision === null ? null : at };
341
+ if (d.clientId !== undefined) patch.clientId = d.clientId === null ? null : String(d.clientId);
342
+ const rows = await tx.update(seazonaHistoryBatchJobs).set(patch)
343
+ .where(and(eq(seazonaHistoryBatchJobs.batchId, batch.id), eq(seazonaHistoryBatchJobs.jobKey, d.jobKey)))
344
+ .returning({ jobKey: seazonaHistoryBatchJobs.jobKey });
345
+ if (!rows.length) throw invalid(`The batch has no job ${d.jobKey}`);
346
+ changed += 1;
347
+ }
348
+ await tx.update(seazonaHistoryBatches).set({ counts: await countBatch(tx, batch.id) }).where(eq(seazonaHistoryBatches.id, batch.id));
349
+ });
350
+ return { changed, ...(await summarise(db, await requireBatch(db, batch.id))) };
351
+ }
352
+
353
+ // Writes batch jobs into Flow's history. Answers { applied, updated }: new
354
+ // jobs, and jobs an earlier batch wrote.
355
+ async function writeHistoryJobs(tx, batchId, jobs, at) {
356
+ let applied = 0;
357
+ let updated = 0;
358
+ for (const part of chunks(jobs)) {
359
+ const values = part.map((j) => ({
360
+ jobKey: j.jobKey,
361
+ invoiceNumber: j.job.invoiceNumber ?? null,
362
+ caseRef: j.job.caseRef ?? null,
363
+ clientId: j.clientId,
364
+ company: j.job.company ?? null,
365
+ doctorName: j.job.doctorName ?? null,
366
+ status: j.job.status ?? null,
367
+ jobDate: j.job.jobDate ?? null,
368
+ dueDate: j.job.dueDate ?? null,
369
+ shippedDate: j.job.shippedDate ?? null,
370
+ lines: j.job.lines ?? [],
371
+ subtotalCents: j.job.subtotalCents ?? null,
372
+ totalCents: j.job.totalCents ?? null,
373
+ patientReference: j.job.patientReference ?? null,
374
+ mirrorOrderId: j.mirrorOrderId,
375
+ mirrorInvoiceNumber: j.mirrorInvoiceNumber,
376
+ recordHash: j.recordHash,
377
+ batchId,
378
+ firstBatchId: batchId,
379
+ firstImportedAt: at,
380
+ lastChangedAt: at,
381
+ }));
382
+ const rows = await tx.insert(seazonaHistoryJobs).values(values).onConflictDoUpdate({
383
+ target: seazonaHistoryJobs.jobKey,
384
+ set: Object.fromEntries(['invoiceNumber', 'caseRef', 'clientId', 'company', 'doctorName', 'status', 'jobDate', 'dueDate', 'shippedDate', 'lines',
385
+ 'subtotalCents', 'totalCents', 'patientReference', 'mirrorOrderId', 'mirrorInvoiceNumber', 'recordHash', 'batchId', 'lastChangedAt']
386
+ .map((field) => [field, sql.raw(`excluded."${seazonaHistoryJobs[field].name}"`)])),
387
+ }).returning({ firstBatchId: seazonaHistoryJobs.firstBatchId });
388
+ for (const row of rows) {
389
+ if (row.firstBatchId === batchId) applied += 1;
390
+ else updated += 1;
391
+ }
392
+ }
393
+ return { applied, updated };
394
+ }
395
+
396
+ // Reconciles once more, then writes each job that apply writes into Flow's
397
+ // history. Jobs waiting for a person stay out; the batch can be applied only
398
+ // once. Answers the batch with `applied` and `updated` counts.
399
+ export async function applyBatch(db, batchId, { by, now = Date.now }) {
400
+ await reconcileBatch(db, batchId, { now });
401
+ const at = new Date(Number(now()));
402
+ const result = await db.transaction(async (tx) => {
403
+ // Locks the batch, so two applies never run at once.
404
+ const [batch] = await tx.execute(sql`select status from seazona_history_batches where id = ${Number(batchId)} for update`).then((r) => r.rows);
405
+ if (batch.status !== 'reconciled') throw conflict(`The batch is ${batch.status}`);
406
+ const jobs = (await tx.select().from(seazonaHistoryBatchJobs).where(eq(seazonaHistoryBatchJobs.batchId, Number(batchId)))).filter(writes);
407
+ const { applied, updated } = await writeHistoryJobs(tx, Number(batchId), jobs, at);
408
+ await tx.update(seazonaHistoryBatches)
409
+ .set({ status: 'applied', appliedAt: at, appliedBy: String(by), counts: { ...(await countBatch(tx, Number(batchId))), applied, updated } })
410
+ .where(eq(seazonaHistoryBatches.id, Number(batchId)));
411
+ return { applied, updated };
412
+ });
413
+ return { ...(await summarise(db, await requireBatch(db, batchId))), ...result };
414
+ }
415
+
416
+ // The API backfill (backfill.js) writes jobs as it reads them: each batch of
417
+ // mapped jobs is staged, reconciled, and the new ones written to history at
418
+ // once, so a long backfill keeps its progress. Jobs that need a person wait
419
+ // in the batch. Answers { staged, applied, waiting }.
420
+ export async function stageApiJobs(db, batchId, jobs, { at }) {
421
+ if (!jobs.length) return { staged: 0, applied: 0, waiting: 0 };
422
+ const rows = jobs.map(({ jobKey, job }) => ({ batchId, jobKey, job, recordHash: hashOf(job) }));
423
+ await db.insert(seazonaHistoryBatchJobs).values(rows).onConflictDoUpdate({
424
+ target: [seazonaHistoryBatchJobs.batchId, seazonaHistoryBatchJobs.jobKey],
425
+ set: { job: sql`excluded.job`, recordHash: sql`excluded.record_hash` },
426
+ });
427
+ const staged = await db.select().from(seazonaHistoryBatchJobs).where(and(
428
+ eq(seazonaHistoryBatchJobs.batchId, batchId),
429
+ inArray(seazonaHistoryBatchJobs.jobKey, rows.map((r) => r.jobKey)),
430
+ ));
431
+ const evidence = await loadEvidence(db, staged);
432
+ return db.transaction(async (tx) => {
433
+ const ready = [];
434
+ let waiting = 0;
435
+ for (const job of staged) {
436
+ const result = reconcileJob(job, evidence);
437
+ await tx.update(seazonaHistoryBatchJobs).set(result)
438
+ .where(and(eq(seazonaHistoryBatchJobs.batchId, batchId), eq(seazonaHistoryBatchJobs.jobKey, job.jobKey)));
439
+ if (writes({ ...job, ...result })) ready.push({ ...job, ...result });
440
+ else if (result.outcome !== 'same') waiting += 1;
441
+ }
442
+ const { applied, updated } = await writeHistoryJobs(tx, batchId, ready, at);
443
+ await tx.update(seazonaHistoryBatches).set({ counts: await countBatch(tx, batchId) }).where(eq(seazonaHistoryBatches.id, batchId));
444
+ return { staged: staged.length, applied: applied + updated, waiting };
445
+ });
446
+ }
447
+
448
+ export async function createApiBatch(db, { createdBy, from, through, at }) {
449
+ const [batch] = await db.insert(seazonaHistoryBatches).values({
450
+ fileName: `Seazona API orders ${from} to ${through}`,
451
+ fileSha256: `api:${from}:${through}:${at.toISOString()}`,
452
+ profile: 'api_orders',
453
+ columns: {},
454
+ ignoredColumns: [],
455
+ status: 'running',
456
+ counts: {},
457
+ createdBy: String(createdBy),
458
+ createdAt: at,
459
+ }).returning();
460
+ return batch;
461
+ }
462
+
463
+ // The backfill finished: the batch waits like an upload, for a person to
464
+ // decide what is left and apply it.
465
+ export async function finishApiBatch(db, batchId, { status = 'reconciled', at }) {
466
+ await db.update(seazonaHistoryBatches).set({ status, reconciledAt: at, counts: await countBatch(db, batchId) })
467
+ .where(eq(seazonaHistoryBatches.id, batchId));
468
+ }
469
+
470
+ export async function cancelBatch(db, batchId) {
471
+ const batch = await requireBatch(db, batchId);
472
+ if (!['staged', 'reconciled'].includes(batch.status)) throw conflict(`The batch is ${batch.status}`);
473
+ await db.update(seazonaHistoryBatches).set({ status: 'cancelled' }).where(eq(seazonaHistoryBatches.id, batch.id));
474
+ return summarise(db, await requireBatch(db, batch.id));
475
+ }
476
+
477
+ // The fields Flow reads from an export, with the headers it knows for each.
478
+ export function describeColumns() {
479
+ return Object.entries(INVOICE_LINE_COLUMNS).map(([field, headers]) => ({ field, headers }));
480
+ }
481
+
482
+ // Past jobs since a cursor, oldest change first, shaped like the billing
483
+ // feed's ledger jobs and marked `historical`, so the core can keep them out
484
+ // of drafting and match them to their accounting invoices. A job the API
485
+ // copy also holds uses the same order id, so the two are one job.
486
+ export async function readHistoryWork(db, { cursor = null, limit = 200 } = {}) {
487
+ let after = null;
488
+ if (cursor) {
489
+ try {
490
+ after = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
491
+ } catch {
492
+ throw new TypeError('Not a Seazona history cursor');
493
+ }
494
+ }
495
+ const pageSize = Math.max(1, Math.min(1000, limit));
496
+ const changedAt = sql`to_char(${seazonaHistoryJobs.lastChangedAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
497
+ const rows = await db.select({ job: seazonaHistoryJobs, changedAt })
498
+ .from(seazonaHistoryJobs)
499
+ .where(after ? sql`(${seazonaHistoryJobs.lastChangedAt}, ${seazonaHistoryJobs.jobKey}) > (${after.at}::timestamptz, ${after.key})` : undefined)
500
+ .orderBy(seazonaHistoryJobs.lastChangedAt, seazonaHistoryJobs.jobKey)
501
+ .limit(pageSize);
502
+ const items = rows.map(({ job }) => {
503
+ const sourceJobRef = job.mirrorOrderId ?? job.caseRef ?? `history:${job.jobKey}`;
504
+ return {
505
+ source: 'seazona',
506
+ historical: true,
507
+ jobKey: job.jobKey,
508
+ sourceId: sourceJobRef,
509
+ clientId: job.clientId,
510
+ company: job.company,
511
+ ledgerJob: {
512
+ sourceSystem: 'seazona',
513
+ sourceJobRef,
514
+ sourceInvoiceRef: job.invoiceNumber ?? job.mirrorInvoiceNumber ?? null,
515
+ caseRef: job.caseRef ?? job.mirrorOrderId ?? null,
516
+ practitioner: job.doctorName,
517
+ shippedOn: job.shippedDate,
518
+ jobDate: job.jobDate ?? job.dueDate ?? job.shippedDate,
519
+ sourceStatus: job.status,
520
+ lines: (job.lines ?? []).map((l) => ({
521
+ itemCode: l.code ?? null,
522
+ description: l.description,
523
+ quantity: 1,
524
+ unitAmountCents: l.amountCents ?? undefined,
525
+ taxCode: typeof l.taxable === 'boolean' ? (l.taxable ? 'GST' : 'GST_FREE') : undefined,
526
+ })),
527
+ observedAt: job.lastChangedAt.toISOString(),
528
+ actor: 'seazona:history_import',
529
+ },
530
+ };
531
+ });
532
+ const last = rows.at(-1);
533
+ return {
534
+ items,
535
+ cursor: last ? Buffer.from(JSON.stringify({ at: last.changedAt, key: last.job.jobKey })).toString('base64url') : cursor,
536
+ more: rows.length === pageSize,
537
+ };
538
+ }
539
+
540
+ // One short phrase for each finding, for the issue on a job card.
541
+ const ISSUE_PHRASE = {
542
+ client_ambiguous: 'Seazona client is unclear',
543
+ api_copy_ambiguous: 'Matches several Seazona orders',
544
+ differs_from_earlier_import: 'Seazona history changed',
545
+ differs_from_api_copy: 'Seazona history differs',
546
+ invoice_has_several_orders: 'Invoice number is on several orders',
547
+ client_unknown: 'Seazona client not found',
548
+ };
549
+ const waitingJobs = sql`${seazonaHistoryBatchJobs.decision} is null and ${seazonaHistoryBatchJobs.outcome} in ('needs_person', 'changed')
550
+ and ${seazonaHistoryBatchJobs.batchId} in (select id from seazona_history_batches where status in ('staged', 'reconciled', 'running'))`;
551
+
552
+ // The history jobs a person must decide, as issues on the jobs they belong
553
+ // to (the port's jobIssues): { [order id]: [{ phrase, href }] }. Each opens
554
+ // the job in the Seazona integration's reconciliation; a decision there
555
+ // clears the issue, since it is read again for each board.
556
+ export async function historyJobIssues(db, refs) {
557
+ const ids = [...new Set((refs ?? []).filter(Boolean).map(String))];
558
+ if (!ids.length) return {};
559
+ const rows = await db.select().from(seazonaHistoryBatchJobs).where(and(
560
+ waitingJobs,
561
+ sql`(${seazonaHistoryBatchJobs.mirrorOrderId} in ${ids} or ${seazonaHistoryBatchJobs.job}->>'caseRef' in ${ids})`,
562
+ ));
563
+ const issues = {};
564
+ for (const row of rows) {
565
+ const ref = ids.includes(row.mirrorOrderId) ? row.mirrorOrderId : row.job.caseRef;
566
+ const code = (row.findings ?? []).find((f) => f.blocking)?.code ?? row.findings?.[0]?.code;
567
+ (issues[ref] ??= []).push({
568
+ integration: 'seazona',
569
+ phrase: ISSUE_PHRASE[code] ?? 'Seazona history needs a check',
570
+ href: `/integrations/seazona?batch=${row.batchId}&job=${encodeURIComponent(row.jobKey)}`,
571
+ });
572
+ }
573
+ return issues;
574
+ }
575
+
576
+ // Totals for health and Settings.
577
+ export async function historySummary(db) {
578
+ const [jobs] = await db.select({ jobs: sql`count(*)::int`, linked: sql`count(${seazonaHistoryJobs.mirrorOrderId})::int`, first: sql`min(coalesce(${seazonaHistoryJobs.jobDate}, ${seazonaHistoryJobs.dueDate}, ${seazonaHistoryJobs.shippedDate}))`, last: sql`max(coalesce(${seazonaHistoryJobs.jobDate}, ${seazonaHistoryJobs.dueDate}, ${seazonaHistoryJobs.shippedDate}))` })
579
+ .from(seazonaHistoryJobs);
580
+ const [batches] = await db.select({ open: sql`count(*) filter (where ${seazonaHistoryBatches.status} in ('staged', 'reconciled'))::int`, applied: sql`count(*) filter (where ${seazonaHistoryBatches.status} = 'applied')::int` })
581
+ .from(seazonaHistoryBatches).where(gt(seazonaHistoryBatches.id, 0));
582
+ const [waiting] = await db.select({ n: sql`count(*)::int` }).from(seazonaHistoryBatchJobs).where(waitingJobs);
583
+ return { jobs: jobs.jobs, alsoInApiCopy: jobs.linked, firstJobDate: jobs.first, lastJobDate: jobs.last, openBatches: batches.open, appliedBatches: batches.applied, waitingForPerson: waiting.n };
584
+ }