@arcforgelabs/flow 2026.10.32 → 2026.10.33
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 +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +56 -24
- package/server/app.js +4 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +3 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +60 -3
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +21 -0
- package/server/integrations/seazona/settings.js +12 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/flow.css +9 -0
- package/server/ui/invoicing.js +101 -17
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -1
package/server/db/schema.js
CHANGED
|
@@ -250,6 +250,9 @@ export const billingObligations = pgTable('billing_obligations', {
|
|
|
250
250
|
holdReason: text('hold_reason'),
|
|
251
251
|
// The Flow invoice made for this job.
|
|
252
252
|
invoiceId: uuid('invoice_id'),
|
|
253
|
+
// The job facts the invoice was built from: the merge base for the
|
|
254
|
+
// three-way compare when the job changes later (server/core/ledger.js).
|
|
255
|
+
draftBasis: jsonb('draft_basis'),
|
|
253
256
|
// What the accounting system reported, how it matched, and when Flow checked.
|
|
254
257
|
accountingState: text('accounting_state').notNull().default('unknown'),
|
|
255
258
|
accountingRef: text('accounting_ref'),
|
package/server/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './import/at-start.js';
|
|
12
12
|
import { createMonitoredLog } from './core/monitoring.js';
|
|
13
13
|
import { createPipelineWorker } from './core/pipeline.js';
|
|
14
|
+
import { createReconcileWorker } from './core/reconcile.js';
|
|
14
15
|
import { createSendWorker } from './core/sending.js';
|
|
15
16
|
import { resolveSecretsKey } from './core/secrets.js';
|
|
16
17
|
import { adapters as shippedAdapters } from './integrations/adapters.js';
|
|
@@ -97,6 +98,10 @@ console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets
|
|
|
97
98
|
if (process.env.FLOW_PIPELINE !== 'off') {
|
|
98
99
|
createPipelineWorker({ db: database.db, integrations, log }).start();
|
|
99
100
|
}
|
|
101
|
+
// The daily reconcile (server/core/reconcile.js) compares Flow's own copies
|
|
102
|
+
// and opens a review item for a day that does not agree. It never calls or
|
|
103
|
+
// writes to another system.
|
|
104
|
+
createReconcileWorker({ db: database.db, integrations, log }).start();
|
|
100
105
|
// The send worker (server/core/sending.js) sends only what a person queued
|
|
101
106
|
// with Send all ready, to the accounting integration that is on.
|
|
102
107
|
createSendWorker({ db: database.db, integrations, log }).start();
|
|
@@ -11,6 +11,7 @@ import { isMissingTable, migrateAdapterSchema } from './schema.js';
|
|
|
11
11
|
|
|
12
12
|
export const CAPABILITIES = Object.freeze([
|
|
13
13
|
'pullWorkItems', // billable work since a cursor (Seazona: completed cases)
|
|
14
|
+
'readWorkItem', // { state: read | withdrawn | unavailable }: one job read from the work system now, as pullWorkItems gives it (read before act, server/core/read-before-act.js)
|
|
14
15
|
'pullHistoryJobs', // past work from a history import, as ledger jobs marked historical (Seazona: its API backfill)
|
|
15
16
|
'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
|
|
16
17
|
'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
|
|
@@ -20,11 +21,13 @@ export const CAPABILITIES = Object.freeze([
|
|
|
20
21
|
'pushBill', // send an approved bill; the Flow record id is the idempotency key
|
|
21
22
|
'pullPayments', // payments since a cursor, matched to invoices and bills
|
|
22
23
|
'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
|
|
24
|
+
'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
|
|
23
25
|
'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
|
|
24
26
|
'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
|
|
25
27
|
'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
|
|
26
28
|
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
|
|
27
29
|
'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
|
|
30
|
+
'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
|
|
28
31
|
'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
|
|
29
32
|
'health', // connection state for Settings
|
|
30
33
|
'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readBillableOrder } from './feed.js';
|
|
2
|
+
import { readOrder } from './lanes.js';
|
|
3
|
+
|
|
4
|
+
// Read before act (issue #141). Just before Flow drafts or sends from a job
|
|
5
|
+
// whose report is old, it reads that one order from Seazona: one
|
|
6
|
+
// GET orders/{id}, through the call guard, stored through the mirror like
|
|
7
|
+
// any lane read. The answer is the order as the feed gives it:
|
|
8
|
+
//
|
|
9
|
+
// { state: 'read', item } billable; `item` is a feed item
|
|
10
|
+
// { state: 'withdrawn', ledgerWithdrawn, reason }
|
|
11
|
+
// deleted in Seazona, or no longer
|
|
12
|
+
// billable
|
|
13
|
+
// { state: 'unavailable', reason } the read failed or calls are stopped
|
|
14
|
+
//
|
|
15
|
+
// The day's counts are kept in memory for /health; a restart starts them again.
|
|
16
|
+
export function createActReader({ now }) {
|
|
17
|
+
let tally = null;
|
|
18
|
+
function count(key) {
|
|
19
|
+
const day = new Date(Number(now())).toISOString().slice(0, 10);
|
|
20
|
+
if (tally?.day !== day) tally = { day, reads: 0, changed: 0, withdrawn: 0, unavailable: 0 };
|
|
21
|
+
tally[key] += 1;
|
|
22
|
+
}
|
|
23
|
+
const unavailable = (reason) => {
|
|
24
|
+
count('unavailable');
|
|
25
|
+
return { state: 'unavailable', reason };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
async function read({ db, client, settings }, orderId) {
|
|
29
|
+
count('reads');
|
|
30
|
+
let result;
|
|
31
|
+
try {
|
|
32
|
+
result = await readOrder({ db, client, sync: settings.sync, at: new Date(Number(now())) }, orderId, { known: true });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return unavailable(error?.message || 'the read failed');
|
|
35
|
+
}
|
|
36
|
+
if (result.outcome === 'failed') return unavailable(result.failure?.error || 'the read failed');
|
|
37
|
+
if (result.summary?.changed || result.summary?.created) count('changed');
|
|
38
|
+
const entry = await readBillableOrder(db, orderId);
|
|
39
|
+
if (!entry) return unavailable('Flow has no copy of the order');
|
|
40
|
+
if (entry.withdrawn) {
|
|
41
|
+
count('withdrawn');
|
|
42
|
+
return { state: 'withdrawn', ledgerWithdrawn: entry.withdrawn.ledgerWithdrawn, reason: entry.withdrawn.reason };
|
|
43
|
+
}
|
|
44
|
+
return { state: 'read', item: entry.item };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { read, status: () => (tally ? { ...tally } : null) };
|
|
48
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
|
|
2
|
-
import { ledgerLinesFor,
|
|
2
|
+
import { ledgerLinesFor, toLedgerJob, toLedgerWithdrawn } from './ledger.js';
|
|
3
3
|
import { isFinalStatus, orderStatusLabel, parseSeazonaDate } from './map.js';
|
|
4
4
|
import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaReference } from './schema.js';
|
|
5
5
|
|
|
@@ -36,11 +36,9 @@ function encodeCursor(at, id) {
|
|
|
36
36
|
return Buffer.from(JSON.stringify({ at, id })).toString('base64url');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const pageSize = Math.max(1, Math.min(1000, limit));
|
|
43
|
-
const rows = await db.select({
|
|
39
|
+
// One order, its invoice and its client, as the feed reads them.
|
|
40
|
+
function feedQuery(db, changedAt) {
|
|
41
|
+
return db.select({
|
|
44
42
|
order: seazonaOrders,
|
|
45
43
|
invoice: seazonaInvoices,
|
|
46
44
|
client: { accountNumber: seazonaClients.accountNumber, company: seazonaClients.company, email: seazonaClients.email },
|
|
@@ -50,56 +48,70 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
|
|
|
50
48
|
})
|
|
51
49
|
.from(seazonaOrders)
|
|
52
50
|
.leftJoin(seazonaInvoices, eq(seazonaInvoices.invoiceNumber, seazonaOrders.invoiceNumber))
|
|
53
|
-
.leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId))
|
|
51
|
+
.leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A feed row as an item, or as a withdrawn entry when it is not billable.
|
|
55
|
+
function toFeedEntry({ order, invoice, client, invoiceOrderCount }) {
|
|
56
|
+
const billable = !order.deletedInSourceAt && BILLABLE.test(order.status ?? '');
|
|
57
|
+
if (!billable) {
|
|
58
|
+
const entry = {
|
|
59
|
+
sourceId: order.id,
|
|
60
|
+
status: orderStatusLabel(order),
|
|
61
|
+
reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
|
|
62
|
+
};
|
|
63
|
+
const syncedAt = order.lastSyncedAt?.toISOString() ?? null;
|
|
64
|
+
return { withdrawn: { ...entry, ledgerWithdrawn: toLedgerWithdrawn({ ...entry, syncedAt }) } };
|
|
65
|
+
}
|
|
66
|
+
const item = {
|
|
67
|
+
source: 'seazona',
|
|
68
|
+
sourceId: order.id,
|
|
69
|
+
invoiceNumber: order.invoiceNumber,
|
|
70
|
+
status: order.status,
|
|
71
|
+
shipped: SHIPPED.test(order.status ?? ''),
|
|
72
|
+
clientId: order.clientId,
|
|
73
|
+
company: order.company,
|
|
74
|
+
doctorName: order.doctorName,
|
|
75
|
+
orderedOn: parseSeazonaDate(order.orderedText),
|
|
76
|
+
dueOn: parseSeazonaDate(order.dueText),
|
|
77
|
+
lines: order.lines,
|
|
78
|
+
subtotalCents: order.subtotalCents,
|
|
79
|
+
patientReference: order.patientReference,
|
|
80
|
+
syncedAt: order.lastSyncedAt.toISOString(),
|
|
81
|
+
invoice: invoice ? {
|
|
82
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
83
|
+
status: invoice.status,
|
|
84
|
+
accountNumber: invoice.accountNumber,
|
|
85
|
+
invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
|
|
86
|
+
dueDate: parseSeazonaDate(invoice.dueText),
|
|
87
|
+
lines: invoice.lines,
|
|
88
|
+
subtotalCents: invoice.subtotalCents,
|
|
89
|
+
taxCents: invoice.taxCents,
|
|
90
|
+
totalCents: invoice.totalCents,
|
|
91
|
+
discountCents: invoice.discountCents,
|
|
92
|
+
patientReference: invoice.patientReference,
|
|
93
|
+
} : null,
|
|
94
|
+
client: client?.accountNumber || client?.company || client?.email ? client : null,
|
|
95
|
+
invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
|
|
96
|
+
};
|
|
97
|
+
return { item: { ...item, linesFrom: ledgerLinesFor(item).linesFrom, ledgerJob: toLedgerJob(item) } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
|
|
101
|
+
const after = decodeCursor(cursor);
|
|
102
|
+
const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}), date_trunc(${SEEN_STEP}, ${seazonaOrders.lastSyncedAt}))`;
|
|
103
|
+
const pageSize = Math.max(1, Math.min(1000, limit));
|
|
104
|
+
const rows = await feedQuery(db, changedAt)
|
|
54
105
|
.where(after ? sql`(${changedAt}, ${seazonaOrders.id}) > (${after.at}::timestamptz, ${after.id})` : undefined)
|
|
55
106
|
.orderBy(changedAt, seazonaOrders.id)
|
|
56
107
|
.limit(pageSize);
|
|
57
108
|
|
|
58
109
|
const items = [];
|
|
59
110
|
const withdrawn = [];
|
|
60
|
-
for (const
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
sourceId: order.id,
|
|
65
|
-
status: orderStatusLabel(order),
|
|
66
|
-
reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
|
|
67
|
-
};
|
|
68
|
-
withdrawn.push({ ...entry, ledgerHold: toLedgerHold(entry) });
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
const item = {
|
|
72
|
-
source: 'seazona',
|
|
73
|
-
sourceId: order.id,
|
|
74
|
-
invoiceNumber: order.invoiceNumber,
|
|
75
|
-
status: order.status,
|
|
76
|
-
shipped: SHIPPED.test(order.status ?? ''),
|
|
77
|
-
clientId: order.clientId,
|
|
78
|
-
company: order.company,
|
|
79
|
-
doctorName: order.doctorName,
|
|
80
|
-
orderedOn: parseSeazonaDate(order.orderedText),
|
|
81
|
-
dueOn: parseSeazonaDate(order.dueText),
|
|
82
|
-
lines: order.lines,
|
|
83
|
-
subtotalCents: order.subtotalCents,
|
|
84
|
-
patientReference: order.patientReference,
|
|
85
|
-
syncedAt: order.lastSyncedAt.toISOString(),
|
|
86
|
-
invoice: invoice ? {
|
|
87
|
-
invoiceNumber: invoice.invoiceNumber,
|
|
88
|
-
status: invoice.status,
|
|
89
|
-
accountNumber: invoice.accountNumber,
|
|
90
|
-
invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
|
|
91
|
-
dueDate: parseSeazonaDate(invoice.dueText),
|
|
92
|
-
lines: invoice.lines,
|
|
93
|
-
subtotalCents: invoice.subtotalCents,
|
|
94
|
-
taxCents: invoice.taxCents,
|
|
95
|
-
totalCents: invoice.totalCents,
|
|
96
|
-
discountCents: invoice.discountCents,
|
|
97
|
-
patientReference: invoice.patientReference,
|
|
98
|
-
} : null,
|
|
99
|
-
client: client?.accountNumber || client?.company || client?.email ? client : null,
|
|
100
|
-
invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
|
|
101
|
-
};
|
|
102
|
-
items.push({ ...item, linesFrom: ledgerLinesFor(item).linesFrom, ledgerJob: toLedgerJob(item) });
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
const entry = toFeedEntry(row);
|
|
113
|
+
if (entry.item) items.push(entry.item);
|
|
114
|
+
else withdrawn.push(entry.withdrawn);
|
|
103
115
|
}
|
|
104
116
|
const lastRow = rows.at(-1);
|
|
105
117
|
return {
|
|
@@ -110,6 +122,14 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
|
|
|
110
122
|
};
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
// One order as the feed gives it (issue #141, read before act): `{ item }`
|
|
126
|
+
// when it is billable, `{ withdrawn }` when it is not, or null when Flow's
|
|
127
|
+
// copy does not have it.
|
|
128
|
+
export async function readBillableOrder(db, orderId) {
|
|
129
|
+
const [row] = await feedQuery(db, seazonaOrders.lastChangedAt).where(eq(seazonaOrders.id, orderId));
|
|
130
|
+
return row ? toFeedEntry(row) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
113
133
|
// Seazona clients, by id and the company name an invoice bills. A client
|
|
114
134
|
// with no company name is listed by its full name. A client the client list
|
|
115
135
|
// never gave (the list can be closed to an API client) is listed by the
|
|
@@ -68,6 +68,30 @@ export class SeazonaCallsStopped extends CallsStopped {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// A request path with its record ids taken out, so /health can say which
|
|
72
|
+
// calls failed and never names a record: /v1/orders/5591234 is
|
|
73
|
+
// /v1/orders/:id. A name is lowercase words joined by hyphens, or a version.
|
|
74
|
+
export function pathShape(path) {
|
|
75
|
+
const name = /^(v\d+|[a-z]+(-[a-z]+)*)$/;
|
|
76
|
+
return `/${String(path || '').split('/').filter(Boolean).map((part) => (name.test(part) ? part : ':id')).join('/')}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Issue #144: the last day's failed calls split into faults (what the call
|
|
80
|
+
// guard counts, and a vendor may count against the API client) and 404s
|
|
81
|
+
// (a record that is gone), and the top groups by path shape, status and
|
|
82
|
+
// kind.
|
|
83
|
+
function failureKinds(day, top = 10) {
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
for (const row of day.failures) {
|
|
86
|
+
const group = { path: pathShape(row.path), statusCode: row.statusCode ?? null, failureKind: row.failureKind ?? null };
|
|
87
|
+
const key = JSON.stringify(group);
|
|
88
|
+
groups.set(key, { ...group, requests: (groups.get(key)?.requests ?? 0) + row.requests });
|
|
89
|
+
}
|
|
90
|
+
const sorted = [...groups.values()].sort((a, b) => b.requests - a.requests).slice(0, top);
|
|
91
|
+
// Every failed call is a fault but a 404 (countsAsFailure, call-guard.js).
|
|
92
|
+
return { faults: day.failed - day.notFound, notFound: day.notFound, failures: sorted };
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
export function createSeazonaGovernor({
|
|
72
96
|
db,
|
|
73
97
|
clientKey,
|
|
@@ -171,7 +195,7 @@ export function createSeazonaGovernor({
|
|
|
171
195
|
const calls = await guard.status(key);
|
|
172
196
|
const [{ lastMinute }] = await db.select({ lastMinute: count() }).from(seazonaApiRequests)
|
|
173
197
|
.where(and(eq(seazonaApiRequests.clientKey, key), gte(seazonaApiRequests.admittedAt, new Date(nowMs - WINDOW_MS))));
|
|
174
|
-
const day = await summarise({ from: new Date(nowMs - 24 * 60 * 60_000), to: new Date(nowMs) });
|
|
198
|
+
const day = await summarise({ from: new Date(nowMs - 24 * 60 * 60_000), to: new Date(nowMs), topFailures: 500 });
|
|
175
199
|
const cooldownUntil = state && state.cooldownUntil.getTime() > nowMs ? state.cooldownUntil.toISOString() : null;
|
|
176
200
|
return {
|
|
177
201
|
limitPerMinute: limit,
|
|
@@ -184,7 +208,7 @@ export function createSeazonaGovernor({
|
|
|
184
208
|
haltedAt: calls.haltedAt,
|
|
185
209
|
failedToday: calls.failedToday,
|
|
186
210
|
dailyFailureBudget: calls.dailyFailureBudget,
|
|
187
|
-
lastDay: { requests: day.requests, failed: day.failed, rateLimited: day.rateLimited, retries: day.retries },
|
|
211
|
+
lastDay: { requests: day.requests, failed: day.failed, rateLimited: day.rateLimited, retries: day.retries, ...failureKinds(day) },
|
|
188
212
|
};
|
|
189
213
|
}
|
|
190
214
|
|
|
@@ -201,6 +225,7 @@ export function createSeazonaGovernor({
|
|
|
201
225
|
failed: sql`count(*) filter (where ${seazonaApiRequests.statusCode} is null or ${seazonaApiRequests.statusCode} >= 400)`,
|
|
202
226
|
rateLimited: sql`count(*) filter (where ${seazonaApiRequests.statusCode} = 429)`,
|
|
203
227
|
retries: sql`count(*) filter (where ${seazonaApiRequests.isRetry})`,
|
|
228
|
+
notFound: sql`count(*) filter (where ${seazonaApiRequests.statusCode} = 404)`,
|
|
204
229
|
}).from(seazonaApiRequests).where(range);
|
|
205
230
|
const failures = await db.select({
|
|
206
231
|
path: seazonaApiRequests.requestPath,
|
|
@@ -219,6 +244,7 @@ export function createSeazonaGovernor({
|
|
|
219
244
|
failed: Number(totals.failed),
|
|
220
245
|
rateLimited: Number(totals.rateLimited),
|
|
221
246
|
retries: Number(totals.retries),
|
|
247
|
+
notFound: Number(totals.notFound),
|
|
222
248
|
failures: failures.map((row) => ({ ...row, requests: Number(row.requests) })),
|
|
223
249
|
};
|
|
224
250
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { guardAlerts } from '../call-guard.js';
|
|
2
|
+
import { createActReader } from './act.js';
|
|
2
3
|
import { createSeazonaClient } from './client.js';
|
|
3
4
|
import { listClients, listProducts, listWork, readBillableWork } from './feed.js';
|
|
4
5
|
import { historyJobIssues, historySummary, readHistoryWork } from './history/index.js';
|
|
5
6
|
import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
|
|
7
|
+
import { readBillableJobs } from './reconcile.js';
|
|
6
8
|
import { SEAZONA_CALL_GUARD } from './governor.js';
|
|
7
9
|
import { createSeazonaRouter } from './routes.js';
|
|
8
10
|
import { orderPatient } from './patients.js';
|
|
9
11
|
import { createSeazonaScheduler } from './scheduler.js';
|
|
12
|
+
import { readSweepHealth } from './sweep.js';
|
|
10
13
|
import { resetFailedLanes, resetStoppedLanes } from './store.js';
|
|
11
14
|
import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
|
|
12
15
|
import { seazonaSchema } from './tables.js';
|
|
@@ -49,8 +52,21 @@ export const SEAZONA_SETUP = {
|
|
|
49
52
|
],
|
|
50
53
|
};
|
|
51
54
|
|
|
55
|
+
// What a person reads for each lane: the Seazona records it copies.
|
|
56
|
+
const READ_NAMES = {
|
|
57
|
+
invoices: 'invoices',
|
|
58
|
+
orders_recent: 'new orders',
|
|
59
|
+
orders_active: 'open orders',
|
|
60
|
+
clients: 'practices',
|
|
61
|
+
products: 'products',
|
|
62
|
+
users: 'lab users',
|
|
63
|
+
history: 'past orders',
|
|
64
|
+
};
|
|
65
|
+
const readNames = (lanes) => lanes.map((lane) => READ_NAMES[lane.lane] ?? lane.lane).join(', ');
|
|
66
|
+
|
|
52
67
|
export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
|
|
53
68
|
let runtime = null; // { settings, db, client, scheduler }
|
|
69
|
+
const actReader = createActReader({ now });
|
|
54
70
|
|
|
55
71
|
function current() {
|
|
56
72
|
if (!runtime) throw new Error('The Seazona adapter has not started');
|
|
@@ -107,13 +123,17 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
107
123
|
if (!runtime) return { ok: false, reason: 'not_started', ...describeSeazonaSettings(null) };
|
|
108
124
|
const { settings, db, client, scheduler } = runtime;
|
|
109
125
|
const sync = await readSeazonaSyncHealth(db, settings, { now });
|
|
126
|
+
const schedulerStatus = scheduler ? scheduler.status() : null;
|
|
110
127
|
return {
|
|
111
|
-
ok: sync.ok,
|
|
128
|
+
ok: sync.ok && !schedulerStatus?.stuckSince,
|
|
112
129
|
...describeSeazonaSettings(settings),
|
|
113
|
-
scheduler:
|
|
130
|
+
scheduler: schedulerStatus,
|
|
114
131
|
governor: await client.governor.status(),
|
|
115
132
|
...sync,
|
|
133
|
+
// Issue #141: today's reads before a draft or a send.
|
|
134
|
+
readsBeforeAct: actReader.status(),
|
|
116
135
|
history: await historySummary(db),
|
|
136
|
+
sweep: await readSweepHealth(db, settings, { now }),
|
|
117
137
|
};
|
|
118
138
|
},
|
|
119
139
|
|
|
@@ -122,7 +142,31 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
122
142
|
// a person saves the settings. Reads Flow's database only.
|
|
123
143
|
async alerts() {
|
|
124
144
|
if (!runtime?.client.governor) return [];
|
|
125
|
-
|
|
145
|
+
const status = await runtime.client.governor.status();
|
|
146
|
+
const alerts = guardAlerts('Seazona', status, { fix: 'Check the API client in the Seazona settings, then save them.' });
|
|
147
|
+
// Issue #143: what the lanes cannot fix by themselves. A refused API
|
|
148
|
+
// client already has its own alert above, so it stops no lane here.
|
|
149
|
+
if (!runtime.settings.scheduled) return alerts;
|
|
150
|
+
const { lanes } = await readSeazonaSyncHealth(runtime.db, runtime.settings, { now });
|
|
151
|
+
const stopped = status.haltedReason ? [] : lanes.filter((lane) => lane.status === 'stopped');
|
|
152
|
+
if (stopped.length) {
|
|
153
|
+
alerts.push({
|
|
154
|
+
id: 'reads-stopped',
|
|
155
|
+
tone: 'danger',
|
|
156
|
+
title: `Seazona refused Flow's request for ${readNames(stopped)}. Flow stopped reading them. Check the Seazona settings, then save them.`,
|
|
157
|
+
members: stopped.map((lane) => `${lane.lane}:${lane.lastAttemptAt ?? ''}`),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const stale = lanes.filter((lane) => lane.stale && lane.status !== 'stopped');
|
|
161
|
+
if (stale.length) {
|
|
162
|
+
alerts.push({
|
|
163
|
+
id: 'reads-late',
|
|
164
|
+
tone: 'warn',
|
|
165
|
+
title: `Flow has not read ${readNames(stale)} from Seazona on time. Jobs and invoices may show old Seazona data.`,
|
|
166
|
+
members: stale.map((lane) => `${lane.lane}:${lane.lastSuccessAt ?? ''}`),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return alerts;
|
|
126
170
|
},
|
|
127
171
|
|
|
128
172
|
// Billable work since a cursor: complete and shipped orders that
|
|
@@ -133,12 +177,25 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
133
177
|
return readBillableWork(current().db, { cursor, ...options });
|
|
134
178
|
},
|
|
135
179
|
|
|
180
|
+
// Read before act (issue #141): reads one order from Seazona now, and
|
|
181
|
+
// answers it as the feed would (act.js). The only capability here that
|
|
182
|
+
// calls Seazona on a person's or an automation's behalf.
|
|
183
|
+
async readWorkItem({ sourceJobRef }) {
|
|
184
|
+
return actReader.read(current(), sourceJobRef);
|
|
185
|
+
},
|
|
186
|
+
|
|
136
187
|
// Past work from the history import (docs/SEAZONA.md, "History
|
|
137
188
|
// import"), as ledger jobs marked `historical`. Reads Flow's copy only.
|
|
138
189
|
async pullHistoryJobs(cursor = null, options = {}) {
|
|
139
190
|
return readHistoryWork(current().db, { cursor, ...options });
|
|
140
191
|
},
|
|
141
192
|
|
|
193
|
+
// Billable jobs by day, for the daily reconcile
|
|
194
|
+
// (server/core/reconcile.js). Reads Flow's copy only.
|
|
195
|
+
async billableJobs({ from = null, to = null } = {}) {
|
|
196
|
+
return readBillableJobs(current().db, { from, to });
|
|
197
|
+
},
|
|
198
|
+
|
|
142
199
|
// Seazona clients by id and company name, for the identity map. Reads
|
|
143
200
|
// Flow's copy only.
|
|
144
201
|
async listCustomers() {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './map.js';
|
|
12
12
|
import { runHistoryBackfill } from './history/backfill.js';
|
|
13
13
|
import { seazonaOrders } from './schema.js';
|
|
14
|
+
import { runSweep } from './sweep.js';
|
|
14
15
|
import {
|
|
15
16
|
claimLane,
|
|
16
17
|
clearOrderFailure,
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
readQuarantinedOrders,
|
|
23
24
|
readQuarantinedRecordCounts,
|
|
24
25
|
readQuarantinedRecords,
|
|
26
|
+
readRecordRereadCounts,
|
|
27
|
+
readRecordsDueForReread,
|
|
25
28
|
recordOrderFailure,
|
|
26
29
|
recordRecordFailure,
|
|
27
30
|
selectActiveOrders,
|
|
@@ -41,6 +44,8 @@ import {
|
|
|
41
44
|
// users the lab user list, daily
|
|
42
45
|
// history past orders, a few calls per run, only while a person
|
|
43
46
|
// has started a backfill (history/backfill.js)
|
|
47
|
+
// sweep whole lists again, slowly, for what the others missed
|
|
48
|
+
// (issue #126, sweep.js)
|
|
44
49
|
//
|
|
45
50
|
// Issue #12 holds for every order read: a 404 marks the order deleted in
|
|
46
51
|
// Seazona, an empty answer is a failure, each failure is recorded per order
|
|
@@ -58,7 +63,7 @@ import {
|
|
|
58
63
|
// only after a person saves the settings or Flow restarts on a new release.
|
|
59
64
|
// Calls the governor stopped (SeazonaCallsStopped) wait out its pause.
|
|
60
65
|
|
|
61
|
-
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history']);
|
|
66
|
+
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history', 'sweep']);
|
|
62
67
|
|
|
63
68
|
const DAY_MS = 24 * 60 * 60_000;
|
|
64
69
|
const MAX_REPORTED = 25;
|
|
@@ -237,6 +242,7 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
237
242
|
at,
|
|
238
243
|
quarantineFailures: sync.orderQuarantineFailures,
|
|
239
244
|
quarantineMs: sync.orderQuarantineMs,
|
|
245
|
+
detailId: id,
|
|
240
246
|
});
|
|
241
247
|
if (recorded.quarantinedUntil) newlyQuarantined += 1;
|
|
242
248
|
failures.push({ id: key, ...failure, ...recorded });
|
|
@@ -257,6 +263,52 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
257
263
|
return { summary, detailReads, listCount: items.length, failureDetails, anyFailed, newlyQuarantined, quarantined: skipped.length };
|
|
258
264
|
}
|
|
259
265
|
|
|
266
|
+
// Issue #142: a quarantined invoice or client is in a later list only if it
|
|
267
|
+
// changes again, so when its wait ends the lane reads its detail by id. A
|
|
268
|
+
// success stores it and clears the failure; a 404 clears the failure (the
|
|
269
|
+
// record is gone, issue #126); a failure quarantines it again, with no new
|
|
270
|
+
// report. Each read goes through the call guard like any other.
|
|
271
|
+
async function rereadReleased(ctx, { mirror, what, detailPath, map }) {
|
|
272
|
+
const { db, client, sync, at } = ctx;
|
|
273
|
+
const due = sync.recordRereadsPerRun ? await readRecordsDueForReread(db, mirror, { at, limit: sync.recordRereadsPerRun }) : [];
|
|
274
|
+
const result = { summary: { created: 0, changed: 0, unchanged: 0 }, detailReads: 0, reread: 0, gone: 0, failed: 0 };
|
|
275
|
+
for (const row of due) {
|
|
276
|
+
result.detailReads += 1;
|
|
277
|
+
try {
|
|
278
|
+
const detail = await client.get(detailPath(row.detailId));
|
|
279
|
+
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`${what} ${row.recordKey}`);
|
|
280
|
+
addSummary(result.summary, await upsertRecords(db, mirror, [map(detail)], { at }));
|
|
281
|
+
await clearRecordFailures(db, mirror, [row.recordKey]);
|
|
282
|
+
result.reread += 1;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (isLaneLevel(error)) throw error;
|
|
285
|
+
if (error?.statusCode === 404) {
|
|
286
|
+
await clearRecordFailures(db, mirror, [row.recordKey]);
|
|
287
|
+
result.gone += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
await recordRecordFailure(db, mirror, row.recordKey, describeReadFailure(error), {
|
|
291
|
+
at,
|
|
292
|
+
quarantineFailures: sync.orderQuarantineFailures,
|
|
293
|
+
quarantineMs: sync.orderQuarantineMs,
|
|
294
|
+
});
|
|
295
|
+
result.failed += 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Adds the re-reads to a lane's result.
|
|
302
|
+
function withRereads(result, reread) {
|
|
303
|
+
if (!reread.detailReads) return result;
|
|
304
|
+
addSummary(result.summary, reread.summary);
|
|
305
|
+
return {
|
|
306
|
+
...result,
|
|
307
|
+
detailReads: result.detailReads + reread.detailReads,
|
|
308
|
+
rereads: { attempted: reread.detailReads, read: reread.reread, gone: reread.gone, failed: reread.failed },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
260
312
|
// Where the next changed-list read starts. A run that stopped inside a
|
|
261
313
|
// window keeps the exact start of the next part (`cursor.since`); otherwise
|
|
262
314
|
// the last finished run's start, less the overlap.
|
|
@@ -351,7 +403,13 @@ const laneRuns = {
|
|
|
351
403
|
const { client, sync, at } = ctx;
|
|
352
404
|
const firstSince = cursorSince(ctx, new Date(floorUtcDay(at.getTime()) - sync.invoiceLookbackDays * DAY_MS).toISOString());
|
|
353
405
|
const mapOptions = { keepPatientReference: sync.keepPatientReference };
|
|
354
|
-
|
|
406
|
+
const reread = await rereadReleased(ctx, {
|
|
407
|
+
mirror: 'invoices',
|
|
408
|
+
what: 'invoice',
|
|
409
|
+
detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
|
|
410
|
+
map: (detail) => mapInvoice({}, detail, mapOptions),
|
|
411
|
+
});
|
|
412
|
+
return withRereads(await walkChangedList(ctx, {
|
|
355
413
|
what: 'invoice',
|
|
356
414
|
firstSince,
|
|
357
415
|
// The guide: on or after lastModified (inclusive).
|
|
@@ -367,7 +425,7 @@ const laneRuns = {
|
|
|
367
425
|
detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
|
|
368
426
|
map: (item, detail) => mapInvoice(item, detail, mapOptions),
|
|
369
427
|
}),
|
|
370
|
-
});
|
|
428
|
+
}), reread);
|
|
371
429
|
},
|
|
372
430
|
|
|
373
431
|
async orders_recent(ctx) {
|
|
@@ -485,10 +543,16 @@ const laneRuns = {
|
|
|
485
543
|
|
|
486
544
|
async clients(ctx) {
|
|
487
545
|
const { client } = ctx;
|
|
546
|
+
const reread = await rereadReleased(ctx, {
|
|
547
|
+
mirror: 'clients',
|
|
548
|
+
what: 'client',
|
|
549
|
+
detailPath: (id) => `clients/${encodeURIComponent(id)}`,
|
|
550
|
+
map: (detail) => mapClient({}, detail),
|
|
551
|
+
});
|
|
488
552
|
// The first run reads the whole list; later runs read changes only.
|
|
489
553
|
// Seazona answers the whole list only to an empty `?lastModified=`; a
|
|
490
554
|
// bare path is a 404.
|
|
491
|
-
return walkChangedList(ctx, {
|
|
555
|
+
return withRereads(await walkChangedList(ctx, {
|
|
492
556
|
what: 'client',
|
|
493
557
|
firstSince: cursorSince(ctx, ''),
|
|
494
558
|
// The guide says "modified after": step back a second so a row in the
|
|
@@ -505,10 +569,11 @@ const laneRuns = {
|
|
|
505
569
|
detailPath: (id) => `clients/${encodeURIComponent(id)}`,
|
|
506
570
|
map: (item, detail) => mapClient(item, detail),
|
|
507
571
|
}),
|
|
508
|
-
});
|
|
572
|
+
}), reread);
|
|
509
573
|
},
|
|
510
574
|
|
|
511
575
|
history: (ctx) => runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
|
|
576
|
+
sweep: (ctx) => runSweep(ctx, { readOrder, isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
|
|
512
577
|
products: (ctx) => syncReference(ctx, 'products'),
|
|
513
578
|
users: (ctx) => syncReference(ctx, 'users'),
|
|
514
579
|
};
|
|
@@ -589,20 +654,27 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
|
|
|
589
654
|
// What /health shows for the sync: each lane's state, the failing and
|
|
590
655
|
// quarantined orders by id and status, and how many orders are marked
|
|
591
656
|
// deleted in Seazona. No patient data.
|
|
657
|
+
//
|
|
658
|
+
// Issue #143: a lane that ran before and has not succeeded within its
|
|
659
|
+
// freshness limit (or two of its own cadences, if those are longer) is
|
|
660
|
+
// stale, and a stale lane makes `ok` false like a failed one.
|
|
592
661
|
export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {}) {
|
|
593
662
|
const nowMs = Number(now());
|
|
594
663
|
const states = await readLaneStates(db);
|
|
595
664
|
const quarantinedOrders = await readQuarantinedOrders(db, { at: new Date(nowMs), limit: MAX_REPORTED });
|
|
596
665
|
const [{ deleted }] = await db.select({ deleted: count() }).from(seazonaOrders).where(isNotNull(seazonaOrders.deletedInSourceAt));
|
|
597
666
|
const quarantinedRecords = await readQuarantinedRecordCounts(db, { at: new Date(nowMs) });
|
|
667
|
+
const rereadDue = await readRecordRereadCounts(db, { at: new Date(nowMs) });
|
|
598
668
|
const lanes = LANES.map((lane) => {
|
|
599
669
|
const state = states.get(lane);
|
|
600
|
-
const { freshnessMs } = settings.sync.lanes[lane];
|
|
670
|
+
const { freshnessMs, cadenceMs } = settings.sync.lanes[lane];
|
|
671
|
+
const staleAfterMs = Math.max(freshnessMs, 2 * cadenceMs);
|
|
601
672
|
const ageMs = state?.lastSuccessAt ? Math.max(0, nowMs - state.lastSuccessAt.getTime()) : null;
|
|
602
673
|
return {
|
|
603
674
|
lane,
|
|
604
675
|
status: state?.status ?? 'never_run',
|
|
605
|
-
fresh: ageMs !== null && ageMs <=
|
|
676
|
+
fresh: ageMs !== null && ageMs <= staleAfterMs,
|
|
677
|
+
stale: ageMs !== null && ageMs > staleAfterMs,
|
|
606
678
|
ageMs,
|
|
607
679
|
lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
|
|
608
680
|
lastAttemptAt: state?.lastAttemptAt?.toISOString() ?? null,
|
|
@@ -613,10 +685,11 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
|
|
|
613
685
|
failureDetails: state?.failureDetails ?? null,
|
|
614
686
|
...(lane === 'orders_active' ? { quarantinedOrders } : {}),
|
|
615
687
|
...(quarantinedRecords[lane] ? { quarantinedRecords: quarantinedRecords[lane] } : {}),
|
|
688
|
+
...(rereadDue[lane] ? { rereadDue: rereadDue[lane] } : {}),
|
|
616
689
|
};
|
|
617
690
|
});
|
|
618
691
|
return {
|
|
619
|
-
ok: lanes.every((lane) => !['failed', 'held', 'stopped'].includes(lane.status)),
|
|
692
|
+
ok: lanes.every((lane) => !lane.stale && !['failed', 'held', 'stopped'].includes(lane.status)),
|
|
620
693
|
lanes,
|
|
621
694
|
deletedOrders: Number(deleted),
|
|
622
695
|
};
|