@arcforgelabs/flow 2026.10.30 → 2026.10.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +125 -7
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +17 -3
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +26 -1
- package/server/core/job-board.js +25 -3
- package/server/core/ledger.js +3 -2
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +76 -4
- package/server/core/price-list.js +348 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +46 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +21 -1
- package/server/integrations/seazona/feed.js +31 -2
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +46 -1
- package/server/integrations/seazona/lanes.js +187 -51
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +39 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +131 -17
- package/server/integrations/xero/index.js +38 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +27 -10
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +55 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +63 -21
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +78 -9
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +4 -2
- package/server/ui/invoicing.js +241 -49
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
import { FlowError, notFound } from './errors.js';
|
|
6
6
|
import { listRules, ruleReasons } from './invoice-rules.js';
|
|
7
7
|
import { draftInvoiceForObligation } from './ledger.js';
|
|
8
|
+
import { checkReferenceTemplate, DEFAULT_REFERENCE, jobReference } from './references.js';
|
|
8
9
|
|
|
9
10
|
// Invoicing rules (docs/INVOICING_RULES.md). They are the checks a job that
|
|
10
11
|
// is ready to invoice passes before Flow drafts its invoice, and how a checked
|
|
@@ -30,6 +31,7 @@ export const INVOICING_DEFAULTS = Object.freeze({
|
|
|
30
31
|
requirePractitioner: false,
|
|
31
32
|
rushItemCode: null,
|
|
32
33
|
rushPercent: 20,
|
|
34
|
+
referenceTemplate: DEFAULT_REFERENCE,
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
const CUSTOMER_DEFAULTS = Object.freeze({
|
|
@@ -58,6 +60,15 @@ function shortText(value, name) {
|
|
|
58
60
|
return value.trim() || null;
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
// An empty rule gives no Reference: the accounting system shows the number.
|
|
64
|
+
function referenceTemplate(value) {
|
|
65
|
+
if (value === null) return '';
|
|
66
|
+
if (typeof value !== 'string' || value.length > 200) throw invalid('referenceTemplate must be text of 200 characters at most');
|
|
67
|
+
const problem = checkReferenceTemplate(value);
|
|
68
|
+
if (problem) throw invalid(`referenceTemplate: ${problem}`);
|
|
69
|
+
return value.trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
function sendMode(value, name) {
|
|
62
73
|
if (!SEND_MODES.includes(value)) throw invalid(`${name} must be one of ${SEND_MODES.join(', ')}`);
|
|
63
74
|
return value;
|
|
@@ -79,6 +90,7 @@ function readSettingChanges(changes) {
|
|
|
79
90
|
else if (key === 'paymentTermsDays') out[key] = wholeNumber(value, key, 0, 365);
|
|
80
91
|
else if (key === 'rushPercent') out[key] = wholeNumber(value, key, 0, 100);
|
|
81
92
|
else if (key === 'rushItemCode') out[key] = shortText(value, key);
|
|
93
|
+
else if (key === 'referenceTemplate') out[key] = referenceTemplate(value);
|
|
82
94
|
else throw invalid(`unknown setting: ${key}`);
|
|
83
95
|
}
|
|
84
96
|
return out;
|
|
@@ -158,6 +170,18 @@ export async function sendModeFor(db, organisationId, contactId) {
|
|
|
158
170
|
return effectiveSettings(settings, customers.get(contactId)).sendMode;
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
// How Flow sends invoices to the accounting system: the organisation's mode,
|
|
174
|
+
// and how many customers have a mode of their own that differs from it.
|
|
175
|
+
export async function sendModeSummary(db, organisationId) {
|
|
176
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
177
|
+
const [row] = await db.select({ n: sql`count(*)::int` }).from(customerBillingSettings).where(and(
|
|
178
|
+
eq(customerBillingSettings.organisationId, organisationId),
|
|
179
|
+
sql`${customerBillingSettings.sendMode} is not null`,
|
|
180
|
+
sql`${customerBillingSettings.sendMode} <> ${settings.sendMode}`,
|
|
181
|
+
));
|
|
182
|
+
return { mode: settings.sendMode, otherCustomers: row?.n ?? 0 };
|
|
183
|
+
}
|
|
184
|
+
|
|
161
185
|
// What a job still waits on before Flow drafts it, as reasons a person can
|
|
162
186
|
// read: Flow's own ready stage, then the lab's invoice rules (invoice-rules.js)
|
|
163
187
|
// in their order. An empty list means the job may be drafted. `rules` is the
|
|
@@ -203,7 +227,7 @@ export function copyCheck(accounting, organisationId) {
|
|
|
203
227
|
// The draft automation (automations.js) runs it. Answers counts.
|
|
204
228
|
// With `accounting` null no accounting system is connected: Flow's own records
|
|
205
229
|
// are the only check, as for a person's draft (ledger.js, ledgerView).
|
|
206
|
-
export async function autoDraftJobs(db, organisationId, { accounting = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
|
|
230
|
+
export async function autoDraftJobs(db, organisationId, { accounting = null, work = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
|
|
207
231
|
const result = { drafted: 0, waiting: 0, off: 0, refused: 0 };
|
|
208
232
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
209
233
|
const rules = await listRules(db, organisationId);
|
|
@@ -243,6 +267,7 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, now
|
|
|
243
267
|
liveCheck,
|
|
244
268
|
actor,
|
|
245
269
|
now,
|
|
270
|
+
reference: await jobReference(db, organisationId, obligation, { template: settings.referenceTemplate, work }),
|
|
246
271
|
dueDate: addDays(invoiceDate, effective.paymentTermsDays),
|
|
247
272
|
});
|
|
248
273
|
result.drafted += 1;
|
package/server/core/job-board.js
CHANGED
|
@@ -3,6 +3,7 @@ import { contacts, invoices, priceItems } from '../db/schema.js';
|
|
|
3
3
|
import { listRules } from './invoice-rules.js';
|
|
4
4
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
|
|
5
5
|
import { listObligations } from './ledger.js';
|
|
6
|
+
import { isHistoryHeld } from './history.js';
|
|
6
7
|
import { invoiceViews } from './sending.js';
|
|
7
8
|
|
|
8
9
|
// The Jobs board (/jobs, server/ui/jobs.js). Its columns are the work
|
|
@@ -68,7 +69,9 @@ const ledgerLines = (lines) => (lines ?? []).map((l) => ({
|
|
|
68
69
|
// Answers { jobs, statuses }. `work` is the adapters that offer listWork;
|
|
69
70
|
// `live` says an accounting system checks the ledger (ledgerView).
|
|
70
71
|
export async function boardJobs(db, organisationId, { work = [], accounting = null, live = false, log = console } = {}) {
|
|
71
|
-
|
|
72
|
+
// Held history jobs are past work, so the board leaves them out
|
|
73
|
+
// (core/history.js); the history import lists them by accounting state.
|
|
74
|
+
const listed = (await listObligations(db, organisationId, { accounting: live })).filter((entry) => !isHistoryHeld(entry.obligation));
|
|
72
75
|
const obligations = await describeObligations(db, organisationId, listed, { accounting });
|
|
73
76
|
const byJob = new Map(obligations.map((o) => [keyOf(o.sourceSystem, o.sourceJobRef), o]));
|
|
74
77
|
|
|
@@ -93,7 +96,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
|
|
|
93
96
|
totalCents: o.totalCents, lines: ledgerLines(o.lines), obligation: o,
|
|
94
97
|
};
|
|
95
98
|
});
|
|
96
|
-
return { jobs, statuses: [...statuses.values()] };
|
|
99
|
+
return { jobs: await withIssues(jobs, work, log), statuses: [...statuses.values()] };
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
const codes = new Set((await db.select({ code: priceItems.code }).from(priceItems)
|
|
@@ -126,5 +129,24 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
|
|
|
126
129
|
});
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
|
-
return { jobs, statuses };
|
|
132
|
+
return { jobs: await withIssues(jobs, work, log), statuses };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Each job's open issues from its integration (the port's jobIssues): a
|
|
136
|
+
// short phrase and the page where a person fixes it. A failed read leaves
|
|
137
|
+
// the board as it is.
|
|
138
|
+
async function withIssues(jobs, adapters, log) {
|
|
139
|
+
const issues = new Map();
|
|
140
|
+
for (const adapter of adapters) {
|
|
141
|
+
if (typeof adapter.capabilities.jobIssues !== 'function') continue;
|
|
142
|
+
const refs = jobs.filter((j) => j.sourceSystem === adapter.id).map((j) => j.sourceJobRef);
|
|
143
|
+
if (!refs.length) continue;
|
|
144
|
+
try {
|
|
145
|
+
const found = await adapter.capabilities.jobIssues({ refs });
|
|
146
|
+
for (const [ref, list] of Object.entries(found ?? {})) issues.set(keyOf(adapter.id, ref), list);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
log.error?.(`[jobs] ${adapter.id} jobIssues: ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return jobs.map((j) => ({ ...j, issues: issues.get(keyOf(j.sourceSystem, j.sourceJobRef)) ?? [] }));
|
|
130
152
|
}
|
package/server/core/ledger.js
CHANGED
|
@@ -399,7 +399,7 @@ function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
|
|
|
399
399
|
// the write, finds no invoice for the job. Any doubt refuses the draft, and
|
|
400
400
|
// the refusal is recorded.
|
|
401
401
|
export async function draftInvoiceForObligation(db, organisationId, obligationId, {
|
|
402
|
-
liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, dueDate = null,
|
|
402
|
+
liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, dueDate = null, reference = null,
|
|
403
403
|
}) {
|
|
404
404
|
requireActor(actor);
|
|
405
405
|
// A null liveCheck says no accounting system is connected (ledgerView).
|
|
@@ -423,7 +423,8 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
|
|
|
423
423
|
}
|
|
424
424
|
const invoice = await createDraftInvoice(tx, organisationId, {
|
|
425
425
|
contactId: obligation.contactId,
|
|
426
|
-
|
|
426
|
+
// The Reference rule's text (references.js), never a work system id.
|
|
427
|
+
reference,
|
|
427
428
|
dueDate,
|
|
428
429
|
lines: obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
|
|
429
430
|
{ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
|
2
|
-
import { LINE_MAPPING_KINDS, lineMappings, pipelineCursors, priceItems } from '../db/schema.js';
|
|
2
|
+
import { LINE_MAPPING_KINDS, lineMappings, pipelineCursors, priceItemLinks, priceItems } from '../db/schema.js';
|
|
3
3
|
import { FlowError, notFound } from './errors.js';
|
|
4
4
|
|
|
5
5
|
// Line mapping (docs/LINE_MAPPING.md): how each line a work system reports
|
|
@@ -8,6 +8,10 @@ import { FlowError, notFound } from './errors.js';
|
|
|
8
8
|
// step reads either the lab's price list or the lab's own mapping rows, so no
|
|
9
9
|
// lab's labels or codes are fixed in Flow's code.
|
|
10
10
|
//
|
|
11
|
+
// Before the steps, a line whose code is a linked product of its work system
|
|
12
|
+
// (the price list's links, server/core/price-list.js) takes that price item.
|
|
13
|
+
// A link is a person's or an exact code's choice, so it always goes first.
|
|
14
|
+
//
|
|
11
15
|
// same_code the line's own code is a price item code
|
|
12
16
|
// account an account row: a line with no price item (a surcharge)
|
|
13
17
|
// saved a row a person saved for this label at this price
|
|
@@ -20,7 +24,8 @@ import { FlowError, notFound } from './errors.js';
|
|
|
20
24
|
//
|
|
21
25
|
// A mapped line keeps the work system's amount and tax flag; the price item
|
|
22
26
|
// gives its code, its text and, when the work system gave none, its price and
|
|
23
|
-
// tax.
|
|
27
|
+
// tax. With `linePrice: 'list'` the price item's price wins instead. A line
|
|
28
|
+
// no step answers keeps no code, so the ledger holds the job.
|
|
24
29
|
|
|
25
30
|
export const STEPS = Object.freeze([
|
|
26
31
|
'same_code', 'account', 'saved', 'catalog_name', 'price_alias', 'catalog_name_price', 'catalog_score', 'alias',
|
|
@@ -36,7 +41,10 @@ export const STEP_LABELS = Object.freeze({
|
|
|
36
41
|
alias: 'Label',
|
|
37
42
|
});
|
|
38
43
|
export const ZERO_LINES = Object.freeze(['keep', 'drop']);
|
|
39
|
-
|
|
44
|
+
// Where a mapped line's price comes from: the work system's amount (as the
|
|
45
|
+
// earlier app did), or the lab's own price list.
|
|
46
|
+
export const LINE_PRICES = Object.freeze(['work', 'list']);
|
|
47
|
+
export const MAPPING_DEFAULTS = Object.freeze({ steps: [...STEPS], zeroLines: 'keep', linePrice: 'work' });
|
|
40
48
|
|
|
41
49
|
const SCORE_MIN = 16;
|
|
42
50
|
const SCORE_GAP = 8;
|
|
@@ -96,8 +104,16 @@ export function nameScore(item, { exact, lookup, original }) {
|
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
// The lab's price list and mapping rows, indexed once for a run.
|
|
99
|
-
export function mappingContext({ items = [], mappings = [], config = MAPPING_DEFAULTS } = {}) {
|
|
107
|
+
export function mappingContext({ items = [], mappings = [], links = [], config = MAPPING_DEFAULTS } = {}) {
|
|
100
108
|
const live = items.filter((i) => !i.archivedAt);
|
|
109
|
+
// A linked work system code: its price item, by the code in lookup form.
|
|
110
|
+
const byId = new Map(live.map((i) => [i.id, i]));
|
|
111
|
+
const byLinkedCode = new Map();
|
|
112
|
+
for (const link of links) {
|
|
113
|
+
const item = byId.get(link.priceItemId);
|
|
114
|
+
const code = lineText(link.externalCode);
|
|
115
|
+
if (item && code && !byLinkedCode.has(code)) byLinkedCode.set(code, item);
|
|
116
|
+
}
|
|
101
117
|
const byCode = new Map(live.map((i) => [i.code, i]));
|
|
102
118
|
const byCodeText = new Map(live.map((i) => [lineText(i.code), i]));
|
|
103
119
|
const byText = new Map();
|
|
@@ -113,7 +129,7 @@ export function mappingContext({ items = [], mappings = [], config = MAPPING_DEF
|
|
|
113
129
|
}
|
|
114
130
|
const rows = mappings.filter((m) => m.enabled !== false);
|
|
115
131
|
const rowsOf = (kind) => rows.filter((m) => m.kind === kind);
|
|
116
|
-
return { byCode, byCodeText, byText, byPrice, rowsOf, config: { ...MAPPING_DEFAULTS, ...config } };
|
|
132
|
+
return { byCode, byCodeText, byText, byPrice, byLinkedCode, rowsOf, config: { ...MAPPING_DEFAULTS, ...config } };
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
const rowMatches = (row, lookup) => (row.match === 'starts_with' ? lookup === row.label || lookup.startsWith(`${row.label} `) : lookup === row.label);
|
|
@@ -139,7 +155,7 @@ export function mapLine(line, context) {
|
|
|
139
155
|
accountCode: null,
|
|
140
156
|
description: (item.description || item.name || original).trim(),
|
|
141
157
|
quantity,
|
|
142
|
-
unitAmountCents: price ?? item.unitAmountCents,
|
|
158
|
+
unitAmountCents: config.linePrice === 'list' ? item.unitAmountCents : (price ?? item.unitAmountCents),
|
|
143
159
|
taxCode: taxCode ?? line.taxCode ?? item.taxCode,
|
|
144
160
|
mappedBy: step,
|
|
145
161
|
});
|
|
@@ -149,6 +165,9 @@ export function mapLine(line, context) {
|
|
|
149
165
|
};
|
|
150
166
|
const unmapped = (reason) => ({ ...base, itemCode: null, accountCode: null, mappedBy: null, unmapped: reason });
|
|
151
167
|
|
|
168
|
+
const linked = line.itemCode ? context.byLinkedCode?.get(lineText(line.itemCode)) : null;
|
|
169
|
+
if (linked) return toItem(linked, 'linked');
|
|
170
|
+
|
|
152
171
|
for (const step of config.steps) {
|
|
153
172
|
switch (step) {
|
|
154
173
|
case 'same_code': {
|
|
@@ -229,6 +248,10 @@ function readConfig(raw = {}) {
|
|
|
229
248
|
if (!ZERO_LINES.includes(raw.zeroLines)) throw invalid(`zeroLines must be one of ${ZERO_LINES.join(', ')}`);
|
|
230
249
|
out.zeroLines = raw.zeroLines;
|
|
231
250
|
}
|
|
251
|
+
if ('linePrice' in raw) {
|
|
252
|
+
if (!LINE_PRICES.includes(raw.linePrice)) throw invalid(`linePrice must be one of ${LINE_PRICES.join(', ')}`);
|
|
253
|
+
out.linePrice = raw.linePrice;
|
|
254
|
+
}
|
|
232
255
|
return out;
|
|
233
256
|
}
|
|
234
257
|
export { readConfig as readMappingConfig };
|
|
@@ -327,11 +350,14 @@ export async function deleteMapping(db, organisationId, id) {
|
|
|
327
350
|
await remapJobs(db, organisationId);
|
|
328
351
|
}
|
|
329
352
|
|
|
330
|
-
// The price list
|
|
331
|
-
export async function loadMappingContext(db, organisationId, config) {
|
|
332
|
-
const [items, mappings] = await Promise.all([
|
|
353
|
+
// The price list, mapping rows and `system`'s product links for a pipeline run.
|
|
354
|
+
export async function loadMappingContext(db, organisationId, config, { system = null } = {}) {
|
|
355
|
+
const [items, mappings, links] = await Promise.all([
|
|
333
356
|
db.select().from(priceItems).where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt))),
|
|
334
357
|
db.select().from(lineMappings).where(eq(lineMappings.organisationId, organisationId)),
|
|
358
|
+
system
|
|
359
|
+
? db.select().from(priceItemLinks).where(and(eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system)))
|
|
360
|
+
: [],
|
|
335
361
|
]);
|
|
336
|
-
return mappingContext({ items, mappings, config });
|
|
362
|
+
return mappingContext({ items, mappings, links, config });
|
|
337
363
|
}
|
package/server/core/pipeline.js
CHANGED
|
@@ -8,7 +8,9 @@ import { listAutomations, runAutomations } from './automations.js';
|
|
|
8
8
|
import { readInvoicingSettings } from './invoicing-rules.js';
|
|
9
9
|
import { observeJob, recordAccountingEvidence, setHold } from './ledger.js';
|
|
10
10
|
import { loadMappingContext, mapLines } from './line-mapping.js';
|
|
11
|
+
import { checkPrices, readCatalogues } from './price-list.js';
|
|
11
12
|
import { linkRecord, priorLinkFor } from './links.js';
|
|
13
|
+
import { HISTORY_ACTOR, HISTORY_HOLD_REASON, isHistoryHeld } from './history.js';
|
|
12
14
|
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
13
15
|
|
|
14
16
|
// The pipeline (docs/DATA_SPINE.md, issue 58). It joins what the integrations
|
|
@@ -138,7 +140,7 @@ export async function ingestWork(db, organisationId, { adapter, restart = false,
|
|
|
138
140
|
const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, unmappedLines: 0, more: false };
|
|
139
141
|
// Line mapping (line-mapping.js): each line finds its price item first.
|
|
140
142
|
const mapping = (await listAutomations(db, organisationId)).find((a) => a.key === 'map_lines');
|
|
141
|
-
const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config) : null;
|
|
143
|
+
const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config, { system: adapter.id }) : null;
|
|
142
144
|
for (let page = 0; page < pages; page += 1) {
|
|
143
145
|
const { items, withdrawn, cursor: next, more } = await adapter.capabilities.pullWorkItems(cursor, { limit: 200 });
|
|
144
146
|
for (const item of items) {
|
|
@@ -175,6 +177,66 @@ export async function ingestWork(db, organisationId, { adapter, restart = false,
|
|
|
175
177
|
return result;
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
// Reads the work system's past jobs (a history import, docs/SEAZONA.md) into
|
|
181
|
+
// the ledger. A job new to the ledger is held with reason `history_import`:
|
|
182
|
+
// it is history, most likely invoiced long ago, so it never drafts until a
|
|
183
|
+
// person releases it, and the matcher still links it to its invoice. A job
|
|
184
|
+
// the ledger already has from the live feed is left as it is: its hold, ready
|
|
185
|
+
// mark and invoice never change here. A held history job that only history
|
|
186
|
+
// has reported takes a later import's changes.
|
|
187
|
+
export async function ingestHistory(db, organisationId, { adapter, pages = WORK_PAGES }) {
|
|
188
|
+
const name = `${adapter.id}.history`;
|
|
189
|
+
let cursor = await readCursor(db, organisationId, name);
|
|
190
|
+
const links = await activeLinks(db, organisationId, { system: adapter.id, entity: 'contact' });
|
|
191
|
+
const contactOf = new Map(links.map((l) => [l.externalId, l.flowId]));
|
|
192
|
+
const result = { held: 0, updated: 0, alreadyLive: 0, skipped: 0, more: false };
|
|
193
|
+
const mapping = (await listAutomations(db, organisationId)).find((a) => a.key === 'map_lines');
|
|
194
|
+
const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config) : null;
|
|
195
|
+
for (let page = 0; page < pages; page += 1) {
|
|
196
|
+
const { items, cursor: next, more } = await adapter.capabilities.pullHistoryJobs(cursor, { limit: 200 });
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
const contactId = contactOf.get(String(item.clientId ?? '')) ?? null;
|
|
199
|
+
const base = { ...item.ledgerJob, actor: HISTORY_ACTOR };
|
|
200
|
+
const job = context ? { ...base, lines: mapLines(base.lines ?? [], context) } : base;
|
|
201
|
+
const [existing] = await db.select().from(billingObligations).where(and(
|
|
202
|
+
eq(billingObligations.organisationId, organisationId),
|
|
203
|
+
eq(billingObligations.sourceSystem, job.sourceSystem),
|
|
204
|
+
eq(billingObligations.sourceJobRef, job.sourceJobRef),
|
|
205
|
+
));
|
|
206
|
+
if (existing) {
|
|
207
|
+
const [live] = existing && isHistoryHeld(existing)
|
|
208
|
+
? await db.select({ id: obligationEvents.id }).from(obligationEvents).where(and(
|
|
209
|
+
eq(obligationEvents.organisationId, organisationId),
|
|
210
|
+
eq(obligationEvents.obligationId, existing.id),
|
|
211
|
+
inArray(obligationEvents.kind, ['observed', 'source_changed']),
|
|
212
|
+
sql`${obligationEvents.actor} <> ${HISTORY_ACTOR}`,
|
|
213
|
+
)).limit(1)
|
|
214
|
+
: [{ id: 'live' }];
|
|
215
|
+
if (live) {
|
|
216
|
+
result.alreadyLive += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
await observeJob(db, organisationId, { ...job, contactId: contactId ?? existing.contactId });
|
|
220
|
+
result.updated += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const row = await observeJob(db, organisationId, { ...job, contactId });
|
|
225
|
+
await setHold(db, organisationId, row.id, { holdState: 'held', reason: HISTORY_HOLD_REASON, actor: HISTORY_ACTOR });
|
|
226
|
+
result.held += 1;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (!(error instanceof FlowError) || error.code !== 'invalid') throw error;
|
|
229
|
+
result.skipped += 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
cursor = next;
|
|
233
|
+
await writeCursor(db, organisationId, name, cursor);
|
|
234
|
+
if (!more) return result;
|
|
235
|
+
}
|
|
236
|
+
result.more = true;
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
178
240
|
const day = (iso) => Date.parse(`${iso}T00:00:00Z`);
|
|
179
241
|
const addDays = (iso, days) => new Date(day(iso) + days * 86_400_000).toISOString().slice(0, 10);
|
|
180
242
|
const daysApart = (a, b) => Math.abs(day(a) - day(b)) / 86_400_000;
|
|
@@ -421,14 +483,24 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
421
483
|
const summary = { organisationId };
|
|
422
484
|
const [work] = integrations.with('pullWorkItems');
|
|
423
485
|
const [accounting] = integrations.with('findInvoices');
|
|
486
|
+
// The price check (price-list.js) links price lists by code first, so a
|
|
487
|
+
// new product link maps the jobs read before it.
|
|
488
|
+
const priceSystems = integrations.with('listPriceItems');
|
|
489
|
+
if (priceSystems.length) {
|
|
490
|
+
summary.prices = await checkPrices(db, organisationId, { catalogues: await readCatalogues(organisationId, priceSystems) });
|
|
491
|
+
}
|
|
424
492
|
if (work) {
|
|
425
|
-
let restart =
|
|
493
|
+
let restart = (summary.prices?.linked ?? 0) > 0;
|
|
426
494
|
if (typeof work.capabilities.listCustomers === 'function') {
|
|
427
495
|
summary.workCustomers = await linkCustomers(db, organisationId, { system: work.id, customers: await work.capabilities.listCustomers({ organisationId }) });
|
|
428
496
|
// New customer links reach jobs read before them.
|
|
429
|
-
restart
|
|
497
|
+
restart ||= summary.workCustomers.linked > 0;
|
|
430
498
|
}
|
|
431
499
|
summary.work = await ingestWork(db, organisationId, { adapter: work, restart });
|
|
500
|
+
// After the live feed, so a job both report is the live one.
|
|
501
|
+
if (typeof work.capabilities.pullHistoryJobs === 'function') {
|
|
502
|
+
summary.history = await ingestHistory(db, organisationId, { adapter: work });
|
|
503
|
+
}
|
|
432
504
|
}
|
|
433
505
|
if (accounting) {
|
|
434
506
|
if (typeof accounting.capabilities.listCustomers === 'function') {
|
|
@@ -439,7 +511,7 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
439
511
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
440
512
|
summary.matching = await matchJobs(db, organisationId, { accounting, now, staleDays: settings.staleDays });
|
|
441
513
|
}
|
|
442
|
-
if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, now });
|
|
514
|
+
if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, work: work ?? null, now });
|
|
443
515
|
return summary;
|
|
444
516
|
}
|
|
445
517
|
|