@arcforgelabs/flow 2026.10.32-beta.1 → 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 +69 -26
- package/server/app.js +4 -0
- package/server/core/automations.js +17 -3
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +64 -5
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +245 -8
- package/server/core/pipeline.js +30 -22
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +52 -8
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +5 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +4 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +77 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +69 -3
- package/server/integrations/seazona/lanes.js +82 -9
- 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/patients.js +30 -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/client.js +12 -0
- package/server/integrations/xero/import.js +9 -3
- package/server/integrations/xero/index.js +7 -1
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +16 -8
- package/server/integrations/xero/routes.js +20 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +346 -0
- package/server/ui/app.js +15 -7
- package/server/ui/flow.css +56 -7
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +3 -1
- package/server/ui/invoicing.js +231 -68
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -1
- package/server/ui/routes.js +1 -1
|
@@ -113,6 +113,20 @@
|
|
|
113
113
|
"when": 1792452497948,
|
|
114
114
|
"tag": "0015_alert_dismissals",
|
|
115
115
|
"breakpoints": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"idx": 16,
|
|
119
|
+
"version": "7",
|
|
120
|
+
"when": 1792456270558,
|
|
121
|
+
"tag": "0016_invoice_reference",
|
|
122
|
+
"breakpoints": true
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"idx": 17,
|
|
126
|
+
"version": "7",
|
|
127
|
+
"when": 1792458167234,
|
|
128
|
+
"tag": "0017_sync_basis",
|
|
129
|
+
"breakpoints": true
|
|
116
130
|
}
|
|
117
131
|
]
|
|
118
132
|
}
|
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'),
|
|
@@ -624,6 +627,8 @@ export const invoicingSettings = pgTable('invoicing_settings', {
|
|
|
624
627
|
// The price item code of a rush charge, and its share of the other lines.
|
|
625
628
|
rushItemCode: text('rush_item_code'),
|
|
626
629
|
rushPercent: integer('rush_percent').notNull().default(20),
|
|
630
|
+
// The invoice Reference, from tokens (server/core/references.js).
|
|
631
|
+
referenceTemplate: text('reference_template').notNull().default('{practitioner} - {patient}'),
|
|
627
632
|
// The fixed checks above became invoice rules (invoice_rules). True once
|
|
628
633
|
// this organisation's starter rules were made from them.
|
|
629
634
|
rulesSeeded: boolean('rules_seeded').notNull().default(false),
|
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,10 +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
|
|
25
|
+
'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
|
|
23
26
|
'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
|
|
24
27
|
'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
|
|
25
28
|
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
|
|
26
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)
|
|
27
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
|
|
28
32
|
'health', // connection state for Settings
|
|
29
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
|
|
|
@@ -11,9 +11,16 @@ import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaReference } from
|
|
|
11
11
|
//
|
|
12
12
|
// The cursor is opaque; pass back `cursor` from the last page. A change to
|
|
13
13
|
// the order or to its invoice moves the order to the end of the feed.
|
|
14
|
+
//
|
|
15
|
+
// A read that found no change moves the order to the end of the feed too, at
|
|
16
|
+
// most once an hour (`SEEN_STEP`). Without it, an order that never changes
|
|
17
|
+
// is reported once, its ledger report gets older than a day, and the ledger
|
|
18
|
+
// refuses its draft as "out of date" for ever, though the lanes read the
|
|
19
|
+
// order every few minutes.
|
|
14
20
|
|
|
15
21
|
const BILLABLE = /\b(complete|completed|shipped|delivered)\b/i;
|
|
16
22
|
const SHIPPED = /\b(shipped|delivered)\b/i;
|
|
23
|
+
const SEEN_STEP = 'hour';
|
|
17
24
|
|
|
18
25
|
function decodeCursor(cursor) {
|
|
19
26
|
if (!cursor) return null;
|
|
@@ -29,11 +36,9 @@ function encodeCursor(at, id) {
|
|
|
29
36
|
return Buffer.from(JSON.stringify({ at, id })).toString('base64url');
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const pageSize = Math.max(1, Math.min(1000, limit));
|
|
36
|
-
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({
|
|
37
42
|
order: seazonaOrders,
|
|
38
43
|
invoice: seazonaInvoices,
|
|
39
44
|
client: { accountNumber: seazonaClients.accountNumber, company: seazonaClients.company, email: seazonaClients.email },
|
|
@@ -43,56 +48,70 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
|
|
|
43
48
|
})
|
|
44
49
|
.from(seazonaOrders)
|
|
45
50
|
.leftJoin(seazonaInvoices, eq(seazonaInvoices.invoiceNumber, seazonaOrders.invoiceNumber))
|
|
46
|
-
.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)
|
|
47
105
|
.where(after ? sql`(${changedAt}, ${seazonaOrders.id}) > (${after.at}::timestamptz, ${after.id})` : undefined)
|
|
48
106
|
.orderBy(changedAt, seazonaOrders.id)
|
|
49
107
|
.limit(pageSize);
|
|
50
108
|
|
|
51
109
|
const items = [];
|
|
52
110
|
const withdrawn = [];
|
|
53
|
-
for (const
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
sourceId: order.id,
|
|
58
|
-
status: orderStatusLabel(order),
|
|
59
|
-
reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
|
|
60
|
-
};
|
|
61
|
-
withdrawn.push({ ...entry, ledgerHold: toLedgerHold(entry) });
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const item = {
|
|
65
|
-
source: 'seazona',
|
|
66
|
-
sourceId: order.id,
|
|
67
|
-
invoiceNumber: order.invoiceNumber,
|
|
68
|
-
status: order.status,
|
|
69
|
-
shipped: SHIPPED.test(order.status ?? ''),
|
|
70
|
-
clientId: order.clientId,
|
|
71
|
-
company: order.company,
|
|
72
|
-
doctorName: order.doctorName,
|
|
73
|
-
orderedOn: parseSeazonaDate(order.orderedText),
|
|
74
|
-
dueOn: parseSeazonaDate(order.dueText),
|
|
75
|
-
lines: order.lines,
|
|
76
|
-
subtotalCents: order.subtotalCents,
|
|
77
|
-
patientReference: order.patientReference,
|
|
78
|
-
syncedAt: order.lastSyncedAt.toISOString(),
|
|
79
|
-
invoice: invoice ? {
|
|
80
|
-
invoiceNumber: invoice.invoiceNumber,
|
|
81
|
-
status: invoice.status,
|
|
82
|
-
accountNumber: invoice.accountNumber,
|
|
83
|
-
invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
|
|
84
|
-
dueDate: parseSeazonaDate(invoice.dueText),
|
|
85
|
-
lines: invoice.lines,
|
|
86
|
-
subtotalCents: invoice.subtotalCents,
|
|
87
|
-
taxCents: invoice.taxCents,
|
|
88
|
-
totalCents: invoice.totalCents,
|
|
89
|
-
discountCents: invoice.discountCents,
|
|
90
|
-
patientReference: invoice.patientReference,
|
|
91
|
-
} : null,
|
|
92
|
-
client: client?.accountNumber || client?.company || client?.email ? client : null,
|
|
93
|
-
invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
|
|
94
|
-
};
|
|
95
|
-
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);
|
|
96
115
|
}
|
|
97
116
|
const lastRow = rows.at(-1);
|
|
98
117
|
return {
|
|
@@ -103,6 +122,14 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
|
|
|
103
122
|
};
|
|
104
123
|
}
|
|
105
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
|
+
|
|
106
133
|
// Seazona clients, by id and the company name an invoice bills. A client
|
|
107
134
|
// with no company name is listed by its full name. A client the client list
|
|
108
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,11 +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';
|
|
10
|
+
import { orderPatient } from './patients.js';
|
|
8
11
|
import { createSeazonaScheduler } from './scheduler.js';
|
|
12
|
+
import { readSweepHealth } from './sweep.js';
|
|
9
13
|
import { resetFailedLanes, resetStoppedLanes } from './store.js';
|
|
10
14
|
import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
|
|
11
15
|
import { seazonaSchema } from './tables.js';
|
|
@@ -44,11 +48,25 @@ export const SEAZONA_SETUP = {
|
|
|
44
48
|
{ name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
|
|
45
49
|
{ name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
|
|
46
50
|
{ name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
51
|
+
{ name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
|
|
47
52
|
],
|
|
48
53
|
};
|
|
49
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
|
+
|
|
50
67
|
export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
|
|
51
68
|
let runtime = null; // { settings, db, client, scheduler }
|
|
69
|
+
const actReader = createActReader({ now });
|
|
52
70
|
|
|
53
71
|
function current() {
|
|
54
72
|
if (!runtime) throw new Error('The Seazona adapter has not started');
|
|
@@ -105,13 +123,17 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
105
123
|
if (!runtime) return { ok: false, reason: 'not_started', ...describeSeazonaSettings(null) };
|
|
106
124
|
const { settings, db, client, scheduler } = runtime;
|
|
107
125
|
const sync = await readSeazonaSyncHealth(db, settings, { now });
|
|
126
|
+
const schedulerStatus = scheduler ? scheduler.status() : null;
|
|
108
127
|
return {
|
|
109
|
-
ok: sync.ok,
|
|
128
|
+
ok: sync.ok && !schedulerStatus?.stuckSince,
|
|
110
129
|
...describeSeazonaSettings(settings),
|
|
111
|
-
scheduler:
|
|
130
|
+
scheduler: schedulerStatus,
|
|
112
131
|
governor: await client.governor.status(),
|
|
113
132
|
...sync,
|
|
133
|
+
// Issue #141: today's reads before a draft or a send.
|
|
134
|
+
readsBeforeAct: actReader.status(),
|
|
114
135
|
history: await historySummary(db),
|
|
136
|
+
sweep: await readSweepHealth(db, settings, { now }),
|
|
115
137
|
};
|
|
116
138
|
},
|
|
117
139
|
|
|
@@ -120,7 +142,31 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
120
142
|
// a person saves the settings. Reads Flow's database only.
|
|
121
143
|
async alerts() {
|
|
122
144
|
if (!runtime?.client.governor) return [];
|
|
123
|
-
|
|
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;
|
|
124
170
|
},
|
|
125
171
|
|
|
126
172
|
// Billable work since a cursor: complete and shipped orders that
|
|
@@ -131,12 +177,25 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
131
177
|
return readBillableWork(current().db, { cursor, ...options });
|
|
132
178
|
},
|
|
133
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
|
+
|
|
134
187
|
// Past work from the history import (docs/SEAZONA.md, "History
|
|
135
188
|
// import"), as ledger jobs marked `historical`. Reads Flow's copy only.
|
|
136
189
|
async pullHistoryJobs(cursor = null, options = {}) {
|
|
137
190
|
return readHistoryWork(current().db, { cursor, ...options });
|
|
138
191
|
},
|
|
139
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
|
+
|
|
140
199
|
// Seazona clients by id and company name, for the identity map. Reads
|
|
141
200
|
// Flow's copy only.
|
|
142
201
|
async listCustomers() {
|
|
@@ -149,6 +208,13 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
149
208
|
return listWork(current().db, { now: now() });
|
|
150
209
|
},
|
|
151
210
|
|
|
211
|
+
// The fields a job gives the invoice Reference: the patient name, when
|
|
212
|
+
// this installation keeps patient names (patients.js).
|
|
213
|
+
async jobReferenceFields({ sourceJobRef, read = false }) {
|
|
214
|
+
const { db, client, settings } = current();
|
|
215
|
+
return orderPatient({ db, client, settings, now }, sourceJobRef, { read });
|
|
216
|
+
},
|
|
217
|
+
|
|
152
218
|
// History jobs a person must decide, as issues on their job cards,
|
|
153
219
|
// each with a link to the reconciliation. Reads Flow's copy only.
|
|
154
220
|
async jobIssues({ refs = [] } = {}) {
|