@arcforgelabs/flow 2026.10.32 → 2026.10.34
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 +9 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +58 -25
- package/server/api/work-moves.js +80 -0
- package/server/app.js +16 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +17 -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/core/tracing.js +64 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +62 -0
- package/server/index.js +11 -1
- package/server/integrations/port.js +10 -1
- 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 +107 -4
- 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/0007_stage_moves.sql +18 -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/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +47 -0
- package/server/integrations/seazona/settings.js +26 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +3 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +57 -9
- package/server/ui/invoicing.js +184 -22
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +22 -2
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
|
@@ -120,6 +120,20 @@
|
|
|
120
120
|
"when": 1792456270558,
|
|
121
121
|
"tag": "0016_invoice_reference",
|
|
122
122
|
"breakpoints": true
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"idx": 17,
|
|
126
|
+
"version": "7",
|
|
127
|
+
"when": 1792458167234,
|
|
128
|
+
"tag": "0017_sync_basis",
|
|
129
|
+
"breakpoints": true
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"idx": 18,
|
|
133
|
+
"version": "7",
|
|
134
|
+
"when": 1792472460808,
|
|
135
|
+
"tag": "0018_custom_fields",
|
|
136
|
+
"breakpoints": true
|
|
123
137
|
}
|
|
124
138
|
]
|
|
125
139
|
}
|
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'),
|
|
@@ -763,3 +766,62 @@ export const priceItemLinks = pgTable('price_item_links', {
|
|
|
763
766
|
check('price_item_links_matched_by_valid', sql`${t.matchedBy} in ('code', 'person')`),
|
|
764
767
|
check('price_item_links_system_valid', sql`${t.system} ~ '^[a-z][a-z0-9-]{1,31}$'`),
|
|
765
768
|
]);
|
|
769
|
+
|
|
770
|
+
// Custom fields (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md): fields
|
|
771
|
+
// a lab makes for its jobs in Flow, in Settings, Custom fields. Only an owner
|
|
772
|
+
// or admin makes or changes one. A dropdown's options are
|
|
773
|
+
// [{ id, name, color }] in lane order; an option a person removes stays in
|
|
774
|
+
// `options` with `archived: true`, so old values and history still read.
|
|
775
|
+
// The type never changes after a field is made.
|
|
776
|
+
export const CUSTOM_FIELD_TYPES = ['dropdown', 'person', 'checkbox', 'date', 'number', 'text'];
|
|
777
|
+
export const jobFields = pgTable('job_fields', {
|
|
778
|
+
id: id(),
|
|
779
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
780
|
+
name: text('name').notNull(),
|
|
781
|
+
type: text('type').notNull(),
|
|
782
|
+
options: jsonb('options').notNull().default(sql`'[]'::jsonb`),
|
|
783
|
+
position: integer('position').notNull(),
|
|
784
|
+
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
785
|
+
createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
|
|
786
|
+
updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
|
|
787
|
+
createdAt: createdAt(),
|
|
788
|
+
updatedAt: updatedAt(),
|
|
789
|
+
}, (t) => [
|
|
790
|
+
unique('job_fields_org_id').on(t.organisationId, t.id),
|
|
791
|
+
check('job_fields_type_valid', sql`${t.type} in ('dropdown', 'person', 'checkbox', 'date', 'number', 'text')`),
|
|
792
|
+
check('job_fields_name_valid', sql`length(${t.name}) between 1 and 60`),
|
|
793
|
+
]);
|
|
794
|
+
|
|
795
|
+
// A job's value for one custom field. A job is the work system's job
|
|
796
|
+
// (source_system, source_job_ref), the same key the Jobs board uses, so a
|
|
797
|
+
// value needs no Flow copy of the job. `value` is JSON: an option id, a person
|
|
798
|
+
// id, true or false, a YYYY-MM-DD date, a number or text. No row means empty.
|
|
799
|
+
export const jobFieldValues = pgTable('job_field_values', {
|
|
800
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
801
|
+
fieldId: uuid('field_id').notNull(),
|
|
802
|
+
sourceSystem: text('source_system').notNull(),
|
|
803
|
+
sourceJobRef: text('source_job_ref').notNull(),
|
|
804
|
+
value: jsonb('value').notNull(),
|
|
805
|
+
updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
|
|
806
|
+
updatedAt: updatedAt(),
|
|
807
|
+
}, (t) => [
|
|
808
|
+
primaryKey({ columns: [t.organisationId, t.fieldId, t.sourceSystem, t.sourceJobRef] }),
|
|
809
|
+
foreignKey({ name: 'job_field_values_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
|
|
810
|
+
index('job_field_values_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef),
|
|
811
|
+
]);
|
|
812
|
+
|
|
813
|
+
// Every change of a job's custom field value: who, when, from and to.
|
|
814
|
+
export const jobFieldEvents = pgTable('job_field_events', {
|
|
815
|
+
id: id(),
|
|
816
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
817
|
+
fieldId: uuid('field_id').notNull(),
|
|
818
|
+
sourceSystem: text('source_system').notNull(),
|
|
819
|
+
sourceJobRef: text('source_job_ref').notNull(),
|
|
820
|
+
fromValue: jsonb('from_value'),
|
|
821
|
+
toValue: jsonb('to_value'),
|
|
822
|
+
actor: uuid('actor').references(() => people.id, { onDelete: 'set null' }),
|
|
823
|
+
createdAt: createdAt(),
|
|
824
|
+
}, (t) => [
|
|
825
|
+
foreignKey({ name: 'job_field_events_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
|
|
826
|
+
index('job_field_events_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef, t.createdAt),
|
|
827
|
+
]);
|
package/server/index.js
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
|
|
11
11
|
} from './import/at-start.js';
|
|
12
12
|
import { createMonitoredLog } from './core/monitoring.js';
|
|
13
|
+
import { createTracing, traceQueries } from './core/tracing.js';
|
|
13
14
|
import { createPipelineWorker } from './core/pipeline.js';
|
|
15
|
+
import { createReconcileWorker } from './core/reconcile.js';
|
|
14
16
|
import { createSendWorker } from './core/sending.js';
|
|
15
17
|
import { resolveSecretsKey } from './core/secrets.js';
|
|
16
18
|
import { adapters as shippedAdapters } from './integrations/adapters.js';
|
|
@@ -77,6 +79,10 @@ await importEarlierMappingsAtStart({ db: database.db, pool: database.pool, setti
|
|
|
77
79
|
// own error service while one is on (docs/SENTRY.md).
|
|
78
80
|
let integrations;
|
|
79
81
|
const log = createMonitoredLog({ log: console, reporters: () => integrations?.with('captureError') ?? [] });
|
|
82
|
+
// Performance traces, while a tracing service is on (docs/SENTRY.md, "Traces"):
|
|
83
|
+
// each request, and each database query inside one.
|
|
84
|
+
const tracing = createTracing({ tracers: () => integrations?.with('traceRequest') ?? [] });
|
|
85
|
+
traceQueries(database.pool, tracing);
|
|
80
86
|
try {
|
|
81
87
|
const stored = await readStoredSettings(database.db);
|
|
82
88
|
const { env: settingsEnv, unreadable } = settingsEnvironment({ env: process.env, stored, adapters: shippedAdapters, secrets });
|
|
@@ -97,6 +103,10 @@ console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets
|
|
|
97
103
|
if (process.env.FLOW_PIPELINE !== 'off') {
|
|
98
104
|
createPipelineWorker({ db: database.db, integrations, log }).start();
|
|
99
105
|
}
|
|
106
|
+
// The daily reconcile (server/core/reconcile.js) compares Flow's own copies
|
|
107
|
+
// and opens a review item for a day that does not agree. It never calls or
|
|
108
|
+
// writes to another system.
|
|
109
|
+
createReconcileWorker({ db: database.db, integrations, log }).start();
|
|
100
110
|
// The send worker (server/core/sending.js) sends only what a person queued
|
|
101
111
|
// with Send all ready, to the accounting integration that is on.
|
|
102
112
|
createSendWorker({ db: database.db, integrations, log }).start();
|
|
@@ -142,7 +152,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
|
|
|
142
152
|
|
|
143
153
|
const updates = createUpdateChecker();
|
|
144
154
|
|
|
145
|
-
createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
|
|
155
|
+
createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing }).listen(port, () => {
|
|
146
156
|
console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
|
|
147
157
|
});
|
|
148
158
|
|
|
@@ -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,16 +21,24 @@ 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
|
-
'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
|
|
26
|
+
'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board; a job may carry `pendingMove`
|
|
27
|
+
'moveWork', // { sourceJobRef, toStatus, personId }: a person moved a job to another stage on the Jobs board; answers what happens (a hand-off when the system's API cannot change it)
|
|
28
|
+
'cancelMove', // { sourceJobRef }: take back a move the work system has not made yet
|
|
29
|
+
'personSettings', // { personId }: each person's own choices in this integration, as [{ key, label, hint, kind, value }]
|
|
30
|
+
'setPersonSetting', // { personId, key, value }: change one, answers the new list
|
|
25
31
|
'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
|
|
26
32
|
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
|
|
27
33
|
'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
|
|
34
|
+
'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
35
|
'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
36
|
'health', // connection state for Settings
|
|
30
37
|
'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
|
|
31
38
|
'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
|
|
32
39
|
'browserMonitoring', // the public settings a browser needs to report errors and problems to that service
|
|
40
|
+
'traceSpan', // runs work inside a span of the current performance trace (server/core/tracing.js)
|
|
41
|
+
'traceRequest', // names a request's performance trace by its route, once the route is known
|
|
33
42
|
]);
|
|
34
43
|
|
|
35
44
|
const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
|
|
@@ -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,18 @@
|
|
|
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 {
|
|
11
|
+
cancelStageMove, expiredStageMoves, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
|
|
12
|
+
} from './stage-moves.js';
|
|
8
13
|
import { orderPatient } from './patients.js';
|
|
9
14
|
import { createSeazonaScheduler } from './scheduler.js';
|
|
15
|
+
import { readSweepHealth } from './sweep.js';
|
|
10
16
|
import { resetFailedLanes, resetStoppedLanes } from './store.js';
|
|
11
17
|
import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
|
|
12
18
|
import { seazonaSchema } from './tables.js';
|
|
@@ -45,12 +51,26 @@ export const SEAZONA_SETUP = {
|
|
|
45
51
|
{ name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
|
|
46
52
|
{ name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
|
|
47
53
|
{ name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
54
|
+
{ name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. The address of one order in Seazona, with {ref} for the order number. Stage moves on the Jobs board open it.' },
|
|
48
55
|
{ 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.' },
|
|
49
56
|
],
|
|
50
57
|
};
|
|
51
58
|
|
|
59
|
+
// What a person reads for each lane: the Seazona records it copies.
|
|
60
|
+
const READ_NAMES = {
|
|
61
|
+
invoices: 'invoices',
|
|
62
|
+
orders_recent: 'new orders',
|
|
63
|
+
orders_active: 'open orders',
|
|
64
|
+
clients: 'practices',
|
|
65
|
+
products: 'products',
|
|
66
|
+
users: 'lab users',
|
|
67
|
+
history: 'past orders',
|
|
68
|
+
};
|
|
69
|
+
const readNames = (lanes) => lanes.map((lane) => READ_NAMES[lane.lane] ?? lane.lane).join(', ');
|
|
70
|
+
|
|
52
71
|
export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
|
|
53
72
|
let runtime = null; // { settings, db, client, scheduler }
|
|
73
|
+
const actReader = createActReader({ now });
|
|
54
74
|
|
|
55
75
|
function current() {
|
|
56
76
|
if (!runtime) throw new Error('The Seazona adapter has not started');
|
|
@@ -107,13 +127,17 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
107
127
|
if (!runtime) return { ok: false, reason: 'not_started', ...describeSeazonaSettings(null) };
|
|
108
128
|
const { settings, db, client, scheduler } = runtime;
|
|
109
129
|
const sync = await readSeazonaSyncHealth(db, settings, { now });
|
|
130
|
+
const schedulerStatus = scheduler ? scheduler.status() : null;
|
|
110
131
|
return {
|
|
111
|
-
ok: sync.ok,
|
|
132
|
+
ok: sync.ok && !schedulerStatus?.stuckSince,
|
|
112
133
|
...describeSeazonaSettings(settings),
|
|
113
|
-
scheduler:
|
|
134
|
+
scheduler: schedulerStatus,
|
|
114
135
|
governor: await client.governor.status(),
|
|
115
136
|
...sync,
|
|
137
|
+
// Issue #141: today's reads before a draft or a send.
|
|
138
|
+
readsBeforeAct: actReader.status(),
|
|
116
139
|
history: await historySummary(db),
|
|
140
|
+
sweep: await readSweepHealth(db, settings, { now }),
|
|
117
141
|
};
|
|
118
142
|
},
|
|
119
143
|
|
|
@@ -122,7 +146,42 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
122
146
|
// a person saves the settings. Reads Flow's database only.
|
|
123
147
|
async alerts() {
|
|
124
148
|
if (!runtime?.client.governor) return [];
|
|
125
|
-
|
|
149
|
+
const status = await runtime.client.governor.status();
|
|
150
|
+
const alerts = guardAlerts('Seazona', status, { fix: 'Check the API client in the Seazona settings, then save them.' });
|
|
151
|
+
// Stage moves nobody made in Seazona within a day (stage-moves.js).
|
|
152
|
+
await settleStageMoves(runtime.db, { now: now() });
|
|
153
|
+
const expired = await expiredStageMoves(runtime.db, { now: now() });
|
|
154
|
+
if (expired.length) {
|
|
155
|
+
alerts.push({
|
|
156
|
+
id: 'stage-moves-expired',
|
|
157
|
+
tone: 'warn',
|
|
158
|
+
title: `${expired.length === 1 ? 'One stage move was' : `${expired.length} stage moves were`} not made in Seazona within a day: ${expired.slice(0, 5).map((m) => `${m.orderId} to ${m.toStatus}`).join(', ')}${expired.length > 5 ? ', …' : ''}. The cards went back to their Seazona stage.`,
|
|
159
|
+
members: expired.map((m) => `${m.orderId}:${m.settledAt?.toISOString() ?? ''}`),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Issue #143: what the lanes cannot fix by themselves. A refused API
|
|
163
|
+
// client already has its own alert above, so it stops no lane here.
|
|
164
|
+
if (!runtime.settings.scheduled) return alerts;
|
|
165
|
+
const { lanes } = await readSeazonaSyncHealth(runtime.db, runtime.settings, { now });
|
|
166
|
+
const stopped = status.haltedReason ? [] : lanes.filter((lane) => lane.status === 'stopped');
|
|
167
|
+
if (stopped.length) {
|
|
168
|
+
alerts.push({
|
|
169
|
+
id: 'reads-stopped',
|
|
170
|
+
tone: 'danger',
|
|
171
|
+
title: `Seazona refused Flow's request for ${readNames(stopped)}. Flow stopped reading them. Check the Seazona settings, then save them.`,
|
|
172
|
+
members: stopped.map((lane) => `${lane.lane}:${lane.lastAttemptAt ?? ''}`),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const stale = lanes.filter((lane) => lane.stale && lane.status !== 'stopped');
|
|
176
|
+
if (stale.length) {
|
|
177
|
+
alerts.push({
|
|
178
|
+
id: 'reads-late',
|
|
179
|
+
tone: 'warn',
|
|
180
|
+
title: `Flow has not read ${readNames(stale)} from Seazona on time. Jobs and invoices may show old Seazona data.`,
|
|
181
|
+
members: stale.map((lane) => `${lane.lane}:${lane.lastSuccessAt ?? ''}`),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return alerts;
|
|
126
185
|
},
|
|
127
186
|
|
|
128
187
|
// Billable work since a cursor: complete and shipped orders that
|
|
@@ -133,12 +192,25 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
133
192
|
return readBillableWork(current().db, { cursor, ...options });
|
|
134
193
|
},
|
|
135
194
|
|
|
195
|
+
// Read before act (issue #141): reads one order from Seazona now, and
|
|
196
|
+
// answers it as the feed would (act.js). The only capability here that
|
|
197
|
+
// calls Seazona on a person's or an automation's behalf.
|
|
198
|
+
async readWorkItem({ sourceJobRef }) {
|
|
199
|
+
return actReader.read(current(), sourceJobRef);
|
|
200
|
+
},
|
|
201
|
+
|
|
136
202
|
// Past work from the history import (docs/SEAZONA.md, "History
|
|
137
203
|
// import"), as ledger jobs marked `historical`. Reads Flow's copy only.
|
|
138
204
|
async pullHistoryJobs(cursor = null, options = {}) {
|
|
139
205
|
return readHistoryWork(current().db, { cursor, ...options });
|
|
140
206
|
},
|
|
141
207
|
|
|
208
|
+
// Billable jobs by day, for the daily reconcile
|
|
209
|
+
// (server/core/reconcile.js). Reads Flow's copy only.
|
|
210
|
+
async billableJobs({ from = null, to = null } = {}) {
|
|
211
|
+
return readBillableJobs(current().db, { from, to });
|
|
212
|
+
},
|
|
213
|
+
|
|
142
214
|
// Seazona clients by id and company name, for the identity map. Reads
|
|
143
215
|
// Flow's copy only.
|
|
144
216
|
async listCustomers() {
|
|
@@ -148,7 +220,38 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
148
220
|
// Open orders and orders finished lately, with their statuses, for the
|
|
149
221
|
// Jobs board. Reads Flow's copy only.
|
|
150
222
|
async listWork() {
|
|
151
|
-
|
|
223
|
+
const { db } = current();
|
|
224
|
+
await settleStageMoves(db, { now: now() });
|
|
225
|
+
const [work, waiting] = await Promise.all([listWork(db, { now: now() }), waitingStageMoves(db)]);
|
|
226
|
+
// A stage move Seazona has not made yet (stage-moves.js): the card
|
|
227
|
+
// shows in its new lane, waiting for Seazona.
|
|
228
|
+
for (const item of work.items) {
|
|
229
|
+
const move = waiting.get(item.sourceJobRef);
|
|
230
|
+
if (move) item.pendingMove = { to: move.toStatus, from: move.fromStatus, requestedAt: move.requestedAt.toISOString(), expiresAt: move.expiresAt.toISOString() };
|
|
231
|
+
}
|
|
232
|
+
return work;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// A person's move of a job to another Seazona stage. Seazona's API
|
|
236
|
+
// cannot change an order's status, so this never calls Seazona: it
|
|
237
|
+
// records a hand-off and answers what the person does in Seazona.
|
|
238
|
+
async moveWork({ sourceJobRef, toStatus, personId = null }) {
|
|
239
|
+
const { db, settings } = current();
|
|
240
|
+
return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId, orderUrlTemplate: settings.orderUrlTemplate, now: now() });
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async cancelMove({ sourceJobRef }) {
|
|
244
|
+
return cancelStageMove(current().db, { orderId: sourceJobRef, now: now() });
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// Each person's own Seazona choices: today, whether the board asks
|
|
248
|
+
// before a stage move.
|
|
249
|
+
async personSettings({ personId }) {
|
|
250
|
+
return readPersonSettings(current().db, personId);
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
async setPersonSetting({ personId, key, value }) {
|
|
254
|
+
return writePersonSetting(current().db, personId, { key, value }, { now: now() });
|
|
152
255
|
},
|
|
153
256
|
|
|
154
257
|
// The fields a job gives the invoice Reference: the patient name, when
|