@arcforgelabs/flow 2026.10.26 → 2026.10.29
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 +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -2
- package/server/api/invoicing.js +313 -46
- package/server/app.js +30 -2
- package/server/core/automations.js +154 -0
- package/server/core/invoice-rules.js +226 -0
- package/server/core/invoices.js +8 -3
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +57 -17
- package/server/core/line-mapping.js +337 -0
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +16 -3
- package/server/core/sending.js +283 -0
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +72 -0
- package/server/db/migrations/0011_invoice_rules.sql +27 -0
- package/server/db/migrations/0012_invoice_sending.sql +18 -0
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +28 -0
- package/server/db/schema.js +158 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +17 -5
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/lanes.js +4 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +127 -17
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +7 -4
- package/server/ui/app.js +122 -6
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +243 -30
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +110 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +232 -84
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +61 -0
- package/server/ui/routes.js +2 -2
- package/server/ui/search.js +145 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
|
2
|
+
import { LINE_MAPPING_KINDS, lineMappings, pipelineCursors, priceItems } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// Line mapping (docs/LINE_MAPPING.md): how each line a work system reports
|
|
6
|
+
// finds the lab's price item before the job reaches the ledger. The routine is
|
|
7
|
+
// a list of steps, tried in order; the first step that answers wins. Every
|
|
8
|
+
// step reads either the lab's price list or the lab's own mapping rows, so no
|
|
9
|
+
// lab's labels or codes are fixed in Flow's code.
|
|
10
|
+
//
|
|
11
|
+
// same_code the line's own code is a price item code
|
|
12
|
+
// account an account row: a line with no price item (a surcharge)
|
|
13
|
+
// saved a row a person saved for this label at this price
|
|
14
|
+
// catalog_name exactly one price item has this name
|
|
15
|
+
// price_alias a row for this label at this price
|
|
16
|
+
// catalog_name_price the price items with this name, at this price: one wins,
|
|
17
|
+
// more than one stops the line for a person
|
|
18
|
+
// catalog_score the price item at this price whose name is closest
|
|
19
|
+
// alias a row for this label, or for the start of it
|
|
20
|
+
//
|
|
21
|
+
// A mapped line keeps the work system's amount and tax flag; the price item
|
|
22
|
+
// gives its code, its text and, when the work system gave none, its price and
|
|
23
|
+
// tax. A line no step answers keeps no code, so the ledger holds the job.
|
|
24
|
+
|
|
25
|
+
export const STEPS = Object.freeze([
|
|
26
|
+
'same_code', 'account', 'saved', 'catalog_name', 'price_alias', 'catalog_name_price', 'catalog_score', 'alias',
|
|
27
|
+
]);
|
|
28
|
+
export const STEP_LABELS = Object.freeze({
|
|
29
|
+
same_code: 'Same code',
|
|
30
|
+
account: 'Account lines',
|
|
31
|
+
saved: 'Saved choices',
|
|
32
|
+
catalog_name: 'Price list name',
|
|
33
|
+
price_alias: 'Label and price',
|
|
34
|
+
catalog_name_price: 'Price list name and price',
|
|
35
|
+
catalog_score: 'Closest name at the same price',
|
|
36
|
+
alias: 'Label',
|
|
37
|
+
});
|
|
38
|
+
export const ZERO_LINES = Object.freeze(['keep', 'drop']);
|
|
39
|
+
export const MAPPING_DEFAULTS = Object.freeze({ steps: [...STEPS], zeroLines: 'keep' });
|
|
40
|
+
|
|
41
|
+
const SCORE_MIN = 16;
|
|
42
|
+
const SCORE_GAP = 8;
|
|
43
|
+
const MAX_MAPPINGS = 2000;
|
|
44
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
45
|
+
|
|
46
|
+
// Text in the form every comparison uses: lower case, "&" as "and", every
|
|
47
|
+
// other mark a space.
|
|
48
|
+
export function lineText(value) {
|
|
49
|
+
return String(value ?? '').trim().toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The label without what varies between orders of the same item: sizes
|
|
53
|
+
// ("2 x 3"), arch ("upper", "lower 4") and "per set" or "per unit".
|
|
54
|
+
export function lineLookupText(value) {
|
|
55
|
+
return lineText(value)
|
|
56
|
+
.replace(/\b\d+\s*x\s*\d+(?:\s+\d+)?\b/g, ' ')
|
|
57
|
+
.replace(/\b(upper|lower)\s+\d+\b/g, ' ')
|
|
58
|
+
.replace(/\b(upper|lower)\b/g, ' ')
|
|
59
|
+
.replace(/\bper set\b/g, ' ')
|
|
60
|
+
.replace(/\bper unit\b/g, ' ')
|
|
61
|
+
.replace(/\s+/g, ' ')
|
|
62
|
+
.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A label that carries its own quantity and unit price: "Teeth (6 x $12.50)".
|
|
66
|
+
export function quantityOf(text) {
|
|
67
|
+
const raw = String(text ?? '').trim();
|
|
68
|
+
const found = raw.match(/\((\d+(?:\.\d+)?)\s*x\s*\$?([0-9]+(?:\.\d{1,2})?)\)/i);
|
|
69
|
+
if (!found) return { base: raw, quantity: 1, unitAmountCents: null };
|
|
70
|
+
const quantity = Number(found[1]);
|
|
71
|
+
const base = raw.replace(found[0], ' ').replace(/\s+/g, ' ').trim();
|
|
72
|
+
return {
|
|
73
|
+
base: base || raw,
|
|
74
|
+
quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
|
|
75
|
+
unitAmountCents: Number.isInteger(quantity) && quantity > 0 ? Math.round(Number(found[2]) * 100) : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const tokens = (text) => [...new Set(text.split(' ').filter((t) => t.length > 1))];
|
|
80
|
+
|
|
81
|
+
// How close a price item's name is to a label. The weights are the earlier
|
|
82
|
+
// lab app's, so its results carry over.
|
|
83
|
+
export function nameScore(item, { exact, lookup, original }) {
|
|
84
|
+
const texts = [lineText(item.name), lineText(item.description)];
|
|
85
|
+
const lookups = [lineLookupText(item.name), lineLookupText(item.description)];
|
|
86
|
+
let score = 0;
|
|
87
|
+
if (exact && texts.includes(exact)) score += 120;
|
|
88
|
+
if (lookup && lookups.includes(lookup)) score += 90;
|
|
89
|
+
if (lookup && lookups.some((t) => t && (t.includes(lookup) || lookup.includes(t)))) score += 35;
|
|
90
|
+
const lookupTokens = new Set(lookups.flatMap(tokens));
|
|
91
|
+
for (const token of tokens(lookup)) if (lookupTokens.has(token)) score += 8;
|
|
92
|
+
const exactTokens = new Set(texts.flatMap(tokens));
|
|
93
|
+
for (const token of tokens(exact)) if (exactTokens.has(token)) score += 8;
|
|
94
|
+
if (original && texts.some((t) => t && t.includes(original))) score += 20;
|
|
95
|
+
return score;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The lab's price list and mapping rows, indexed once for a run.
|
|
99
|
+
export function mappingContext({ items = [], mappings = [], config = MAPPING_DEFAULTS } = {}) {
|
|
100
|
+
const live = items.filter((i) => !i.archivedAt);
|
|
101
|
+
const byCode = new Map(live.map((i) => [i.code, i]));
|
|
102
|
+
const byCodeText = new Map(live.map((i) => [lineText(i.code), i]));
|
|
103
|
+
const byText = new Map();
|
|
104
|
+
const byPrice = new Map();
|
|
105
|
+
const add = (map, key, item) => {
|
|
106
|
+
if (!key) return;
|
|
107
|
+
if (!map.has(key)) map.set(key, []);
|
|
108
|
+
if (!map.get(key).includes(item)) map.get(key).push(item);
|
|
109
|
+
};
|
|
110
|
+
for (const item of live) {
|
|
111
|
+
for (const text of [lineText(item.name), lineText(item.description), lineLookupText(item.name), lineLookupText(item.description)]) add(byText, text, item);
|
|
112
|
+
add(byPrice, item.unitAmountCents, item);
|
|
113
|
+
}
|
|
114
|
+
const rows = mappings.filter((m) => m.enabled !== false);
|
|
115
|
+
const rowsOf = (kind) => rows.filter((m) => m.kind === kind);
|
|
116
|
+
return { byCode, byCodeText, byText, byPrice, rowsOf, config: { ...MAPPING_DEFAULTS, ...config } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rowMatches = (row, lookup) => (row.match === 'starts_with' ? lookup === row.label || lookup.startsWith(`${row.label} `) : lookup === row.label);
|
|
120
|
+
|
|
121
|
+
// Maps one line. Answers the line as the ledger takes it, with `mappedBy` (the
|
|
122
|
+
// step) and `label` (the lookup text), or `skip: true` for a zero line the
|
|
123
|
+
// routine drops. A line no step answers comes back with `unmapped` set.
|
|
124
|
+
export function mapLine(line, context) {
|
|
125
|
+
const { config } = context;
|
|
126
|
+
const original = String(line.description ?? '').trim();
|
|
127
|
+
const meta = quantityOf(original);
|
|
128
|
+
const exact = lineText(meta.base);
|
|
129
|
+
const lookup = lineLookupText(meta.base);
|
|
130
|
+
const lineCents = line.unitAmountCents ?? null;
|
|
131
|
+
const price = meta.unitAmountCents ?? lineCents;
|
|
132
|
+
const quantity = meta.unitAmountCents !== null ? meta.quantity : (line.quantity ?? 1);
|
|
133
|
+
const base = { ...line, label: lookup || exact || null };
|
|
134
|
+
if (lineCents !== null && lineCents <= 0 && config.zeroLines === 'drop') return { ...base, skip: true, mappedBy: 'zero_line' };
|
|
135
|
+
|
|
136
|
+
const toItem = (item, step, taxCode = null) => ({
|
|
137
|
+
...base,
|
|
138
|
+
itemCode: item.code,
|
|
139
|
+
accountCode: null,
|
|
140
|
+
description: (item.description || item.name || original).trim(),
|
|
141
|
+
quantity,
|
|
142
|
+
unitAmountCents: price ?? item.unitAmountCents,
|
|
143
|
+
taxCode: taxCode ?? line.taxCode ?? item.taxCode,
|
|
144
|
+
mappedBy: step,
|
|
145
|
+
});
|
|
146
|
+
const fromRow = (row, step) => {
|
|
147
|
+
const item = context.byCode.get(row.itemCode);
|
|
148
|
+
return item ? toItem(item, step, row.taxCode) : null;
|
|
149
|
+
};
|
|
150
|
+
const unmapped = (reason) => ({ ...base, itemCode: null, accountCode: null, mappedBy: null, unmapped: reason });
|
|
151
|
+
|
|
152
|
+
for (const step of config.steps) {
|
|
153
|
+
switch (step) {
|
|
154
|
+
case 'same_code': {
|
|
155
|
+
const item = line.itemCode ? context.byCode.get(line.itemCode.trim()) ?? context.byCodeText.get(lineText(line.itemCode)) : null;
|
|
156
|
+
if (item) return toItem(item, step);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case 'account': {
|
|
160
|
+
const row = context.rowsOf('account').find((r) => rowMatches(r, lookup));
|
|
161
|
+
if (row) {
|
|
162
|
+
return {
|
|
163
|
+
...base, itemCode: null, accountCode: row.accountCode, description: row.description || original, quantity,
|
|
164
|
+
unitAmountCents: price, taxCode: row.taxCode ?? line.taxCode, mappedBy: step,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'saved':
|
|
170
|
+
case 'price_alias': {
|
|
171
|
+
if (price === null) break;
|
|
172
|
+
const row = context.rowsOf(step).find((r) => r.unitAmountCents === price && rowMatches(r, lookup));
|
|
173
|
+
const mapped = row && fromRow(row, step);
|
|
174
|
+
if (mapped) return mapped;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case 'catalog_name': {
|
|
178
|
+
const found = [...new Set([...(context.byText.get(exact) ?? []), ...(context.byText.get(lookup) ?? [])])];
|
|
179
|
+
if (found.length === 1) return toItem(found[0], step);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'catalog_name_price': {
|
|
183
|
+
if (price === null) break;
|
|
184
|
+
const found = [...new Set([...(context.byText.get(exact) ?? []), ...(context.byText.get(lookup) ?? [])])]
|
|
185
|
+
.filter((i) => i.unitAmountCents === price);
|
|
186
|
+
if (found.length === 1) return toItem(found[0], step);
|
|
187
|
+
if (found.length > 1) return unmapped(`more than one price item is named "${original}" at this price`);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'catalog_score': {
|
|
191
|
+
if (price === null) break;
|
|
192
|
+
const scored = (context.byPrice.get(price) ?? [])
|
|
193
|
+
.map((item) => ({ item, score: nameScore(item, { exact, lookup, original: lineText(original) }) }))
|
|
194
|
+
.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name) || a.item.code.localeCompare(b.item.code));
|
|
195
|
+
const [top, next] = scored;
|
|
196
|
+
if (top && top.score >= SCORE_MIN && !(next && top.score < next.score + SCORE_GAP)) return toItem(top.item, step);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'alias': {
|
|
200
|
+
const rows = context.rowsOf('alias');
|
|
201
|
+
const row = rows.find((r) => r.match === 'exact' && rowMatches(r, lookup)) ?? rows.find((r) => r.match === 'starts_with' && rowMatches(r, lookup));
|
|
202
|
+
const mapped = row && fromRow(row, step);
|
|
203
|
+
if (mapped) return mapped;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return unmapped(original ? `no mapping step found a price item for "${original}"` : 'the line has no text');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Maps every line of a job. Lines the routine drops leave the job.
|
|
214
|
+
export function mapLines(lines, context) {
|
|
215
|
+
return lines.map((line) => mapLine(line, context)).filter((l) => !l.skip)
|
|
216
|
+
.map(({ skip: _skip, unmapped: _unmapped, ...line }) => line);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readConfig(raw = {}) {
|
|
220
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the routine settings must be an object');
|
|
221
|
+
const out = { ...MAPPING_DEFAULTS };
|
|
222
|
+
if ('steps' in raw) {
|
|
223
|
+
if (!Array.isArray(raw.steps) || raw.steps.some((s) => !STEPS.includes(s)) || new Set(raw.steps).size !== raw.steps.length) {
|
|
224
|
+
throw invalid(`steps must list some of ${STEPS.join(', ')}, each once`);
|
|
225
|
+
}
|
|
226
|
+
out.steps = raw.steps;
|
|
227
|
+
}
|
|
228
|
+
if ('zeroLines' in raw) {
|
|
229
|
+
if (!ZERO_LINES.includes(raw.zeroLines)) throw invalid(`zeroLines must be one of ${ZERO_LINES.join(', ')}`);
|
|
230
|
+
out.zeroLines = raw.zeroLines;
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
export { readConfig as readMappingConfig };
|
|
235
|
+
|
|
236
|
+
const shape = (r) => ({
|
|
237
|
+
id: r.id, kind: r.kind, match: r.match, label: r.label, unitAmountCents: r.unitAmountCents, itemCode: r.itemCode,
|
|
238
|
+
accountCode: r.accountCode, taxCode: r.taxCode, description: r.description, enabled: r.enabled, origin: r.origin, updatedAt: r.updatedAt,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const shortText = (value, name, max = 200) => {
|
|
242
|
+
if (value === null || value === undefined || value === '') return null;
|
|
243
|
+
if (typeof value !== 'string' || value.trim().length > max) throw invalid(`${name} must be text of ${max} characters at most`);
|
|
244
|
+
return value.trim() || null;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export function readMapping(raw) {
|
|
248
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the mapping must be an object');
|
|
249
|
+
const known = ['kind', 'match', 'label', 'unitAmountCents', 'itemCode', 'accountCode', 'taxCode', 'description', 'enabled'];
|
|
250
|
+
const unknown = Object.keys(raw).filter((k) => !known.includes(k));
|
|
251
|
+
if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
|
|
252
|
+
if (!LINE_MAPPING_KINDS.includes(raw.kind)) throw invalid(`kind must be one of ${LINE_MAPPING_KINDS.join(', ')}`);
|
|
253
|
+
const match = raw.match ?? 'exact';
|
|
254
|
+
if (!['exact', 'starts_with'].includes(match)) throw invalid('match must be exact or starts_with');
|
|
255
|
+
const label = lineLookupText(raw.label) || lineText(raw.label);
|
|
256
|
+
if (!label || label.length > 200) throw invalid('the label must be text of 200 characters at most');
|
|
257
|
+
const priced = ['price_alias', 'saved'].includes(raw.kind);
|
|
258
|
+
let unitAmountCents = raw.unitAmountCents ?? null;
|
|
259
|
+
if (unitAmountCents !== null && (!Number.isInteger(unitAmountCents) || unitAmountCents < 0 || unitAmountCents > 100_000_000)) {
|
|
260
|
+
throw invalid('unitAmountCents must be whole cents');
|
|
261
|
+
}
|
|
262
|
+
if (priced && unitAmountCents === null) throw invalid('this kind needs a unit price');
|
|
263
|
+
if (!priced) unitAmountCents = null;
|
|
264
|
+
const taxCode = raw.taxCode ?? null;
|
|
265
|
+
if (taxCode !== null && !['GST', 'GST_FREE'].includes(taxCode)) throw invalid('taxCode must be GST or GST_FREE');
|
|
266
|
+
const itemCode = shortText(raw.itemCode, 'itemCode', 100);
|
|
267
|
+
const accountCode = shortText(raw.accountCode, 'accountCode', 20);
|
|
268
|
+
if (raw.kind === 'account' ? !accountCode : !itemCode) throw invalid(raw.kind === 'account' ? 'an account line needs an account code' : 'the mapping needs a price item');
|
|
269
|
+
const enabled = raw.enabled ?? true;
|
|
270
|
+
if (typeof enabled !== 'boolean') throw invalid('enabled must be true or false');
|
|
271
|
+
return {
|
|
272
|
+
kind: raw.kind, match, label, unitAmountCents,
|
|
273
|
+
itemCode: raw.kind === 'account' ? null : itemCode,
|
|
274
|
+
accountCode: raw.kind === 'account' ? accountCode : null,
|
|
275
|
+
taxCode, description: shortText(raw.description, 'description'), enabled,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function listMappings(db, organisationId) {
|
|
280
|
+
const rows = await db.select().from(lineMappings).where(eq(lineMappings.organisationId, organisationId))
|
|
281
|
+
.orderBy(asc(lineMappings.kind), asc(lineMappings.label), asc(lineMappings.unitAmountCents));
|
|
282
|
+
return rows.map(shape);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A change to the mappings reads every job again on the next pipeline run, so
|
|
286
|
+
// jobs already in the ledger take the new mapping. It reads Flow's own copy of
|
|
287
|
+
// the work system, not the work system.
|
|
288
|
+
export async function remapJobs(db, organisationId) {
|
|
289
|
+
await db.delete(pipelineCursors).where(and(eq(pipelineCursors.organisationId, organisationId), sql`${pipelineCursors.name} like '%.work'`));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function createMapping(db, organisationId, raw, { personId = null, origin = 'person' } = {}) {
|
|
293
|
+
const mapping = readMapping(raw);
|
|
294
|
+
const [{ count }] = await db.select({ count: sql`count(*)::int` }).from(lineMappings).where(eq(lineMappings.organisationId, organisationId));
|
|
295
|
+
if (count >= MAX_MAPPINGS) throw invalid(`an organisation can have ${MAX_MAPPINGS} line mappings at most`);
|
|
296
|
+
const [row] = await db.insert(lineMappings).values({ organisationId, ...mapping, origin, updatedBy: personId })
|
|
297
|
+
.onConflictDoNothing().returning();
|
|
298
|
+
if (!row) throw new FlowError('conflict', 'a mapping for this label already exists');
|
|
299
|
+
await remapJobs(db, organisationId);
|
|
300
|
+
return shape(row);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function loadMapping(db, organisationId, id) {
|
|
304
|
+
const [row] = await db.select().from(lineMappings).where(and(eq(lineMappings.organisationId, organisationId), eq(lineMappings.id, id)));
|
|
305
|
+
if (!row) throw notFound('mapping');
|
|
306
|
+
return row;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function updateMapping(db, organisationId, id, changes, { personId = null } = {}) {
|
|
310
|
+
const row = await loadMapping(db, organisationId, id);
|
|
311
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
|
|
312
|
+
const mapping = readMapping({ ...shape(row), ...changes, id: undefined, origin: undefined, updatedAt: undefined });
|
|
313
|
+
try {
|
|
314
|
+
const [saved] = await db.update(lineMappings).set({ ...mapping, updatedBy: personId, updatedAt: sql`now()` })
|
|
315
|
+
.where(eq(lineMappings.id, row.id)).returning();
|
|
316
|
+
await remapJobs(db, organisationId);
|
|
317
|
+
return shape(saved);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error?.code === '23505' || error?.cause?.code === '23505') throw new FlowError('conflict', 'a mapping for this label already exists');
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function deleteMapping(db, organisationId, id) {
|
|
325
|
+
const row = await loadMapping(db, organisationId, id);
|
|
326
|
+
await db.delete(lineMappings).where(eq(lineMappings.id, row.id));
|
|
327
|
+
await remapJobs(db, organisationId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The price list and mapping rows for a pipeline run.
|
|
331
|
+
export async function loadMappingContext(db, organisationId, config) {
|
|
332
|
+
const [items, mappings] = await Promise.all([
|
|
333
|
+
db.select().from(priceItems).where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt))),
|
|
334
|
+
db.select().from(lineMappings).where(eq(lineMappings.organisationId, organisationId)),
|
|
335
|
+
]);
|
|
336
|
+
return mappingContext({ items, mappings, config });
|
|
337
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The logger every part of Flow writes to. It passes each line to the host's
|
|
2
|
+
// own log first, unchanged, then sends each error to the error services that
|
|
3
|
+
// are on (the `captureError` capability, docs/SENTRY.md). With none on, it is
|
|
4
|
+
// the host's log. A line's leading "[tag]" is its source.
|
|
5
|
+
//
|
|
6
|
+
// `reporters()` answers the adapters to send to; it is read at each error,
|
|
7
|
+
// so an integration turned on or off later is followed. A failure to send is
|
|
8
|
+
// logged as a warning at most once a minute, never sent, so it cannot loop.
|
|
9
|
+
export function createMonitoredLog({ log = console, reporters = () => [], now = Date.now }) {
|
|
10
|
+
let lastFailure = -Infinity;
|
|
11
|
+
|
|
12
|
+
function error(...args) {
|
|
13
|
+
log.error?.(...args);
|
|
14
|
+
const cause = args.find((arg) => arg instanceof Error) ?? null;
|
|
15
|
+
const message = args.map((arg) => (arg instanceof Error ? arg.message : String(arg))).join(' ');
|
|
16
|
+
const source = /^\[([a-z0-9_-]+)\]/i.exec(message)?.[1].toLowerCase() ?? 'server';
|
|
17
|
+
for (const adapter of reporters()) {
|
|
18
|
+
try {
|
|
19
|
+
adapter.capabilities.captureError({ message, source, error: cause });
|
|
20
|
+
} catch (failure) {
|
|
21
|
+
const at = now();
|
|
22
|
+
if (at - lastFailure < 60_000) continue;
|
|
23
|
+
lastFailure = at;
|
|
24
|
+
log.warn?.(`[monitoring] ${adapter.id} could not take an error: ${failure.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
info: (...args) => log.info?.(...args),
|
|
31
|
+
warn: (...args) => log.warn?.(...args),
|
|
32
|
+
error,
|
|
33
|
+
};
|
|
34
|
+
}
|
package/server/core/pipeline.js
CHANGED
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
billingObligations, contacts, externalLinks, linkRejections, linkSuggestions, obligationEvents, organisations, pipelineCursors,
|
|
5
5
|
} from '../db/schema.js';
|
|
6
6
|
import { FlowError } from './errors.js';
|
|
7
|
+
import { listAutomations, runAutomations } from './automations.js';
|
|
8
|
+
import { readInvoicingSettings } from './invoicing-rules.js';
|
|
7
9
|
import { observeJob, recordAccountingEvidence, setHold } from './ledger.js';
|
|
10
|
+
import { loadMappingContext, mapLines } from './line-mapping.js';
|
|
8
11
|
import { linkRecord, priorLinkFor } from './links.js';
|
|
9
12
|
import { enqueueJob, runNextJob } from './queue.js';
|
|
10
13
|
|
|
@@ -20,6 +23,9 @@ import { enqueueJob, runNextJob } from './queue.js';
|
|
|
20
23
|
// sales invoices. Exact evidence links the job. A likely invoice is a
|
|
21
24
|
// suggestion for a person. When the copy is complete and fresh and holds no
|
|
22
25
|
// invoice that could bill the job, the job has none.
|
|
26
|
+
// 4. Automations (automations.js): the routines that are on, for example
|
|
27
|
+
// marking a job ready to invoice from its work system status, and drafting
|
|
28
|
+
// the invoice of a ready job. Drafts wait in Flow for a person.
|
|
23
29
|
//
|
|
24
30
|
// The matcher reads only Flow's copies, so matching years of history costs no
|
|
25
31
|
// API calls (docs/JOB_INVOICE_LINKS.md).
|
|
@@ -129,13 +135,18 @@ export async function ingestWork(db, organisationId, { adapter, restart = false,
|
|
|
129
135
|
let cursor = restart ? null : await readCursor(db, organisationId, name);
|
|
130
136
|
const links = await activeLinks(db, organisationId, { system: adapter.id, entity: 'contact' });
|
|
131
137
|
const contactOf = new Map(links.map((l) => [l.externalId, l.flowId]));
|
|
132
|
-
const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, more: false };
|
|
138
|
+
const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, unmappedLines: 0, more: false };
|
|
139
|
+
// Line mapping (line-mapping.js): each line finds its price item first.
|
|
140
|
+
const mapping = (await listAutomations(db, organisationId)).find((a) => a.key === 'map_lines');
|
|
141
|
+
const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config) : null;
|
|
133
142
|
for (let page = 0; page < pages; page += 1) {
|
|
134
143
|
const { items, withdrawn, cursor: next, more } = await adapter.capabilities.pullWorkItems(cursor, { limit: 200 });
|
|
135
144
|
for (const item of items) {
|
|
136
145
|
const contactId = contactOf.get(String(item.clientId ?? '')) ?? null;
|
|
146
|
+
const job = context ? { ...item.ledgerJob, lines: mapLines(item.ledgerJob.lines ?? [], context) } : item.ledgerJob;
|
|
147
|
+
if (context) result.unmappedLines += job.lines.filter((l) => !l.itemCode && !l.accountCode).length;
|
|
137
148
|
try {
|
|
138
|
-
await observeJob(db, organisationId, { ...
|
|
149
|
+
await observeJob(db, organisationId, { ...job, contactId });
|
|
139
150
|
result.observed += 1;
|
|
140
151
|
if (!contactId) result.withoutCustomer += 1;
|
|
141
152
|
} catch (error) {
|
|
@@ -425,8 +436,10 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
425
436
|
system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }),
|
|
426
437
|
});
|
|
427
438
|
}
|
|
428
|
-
|
|
439
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
440
|
+
summary.matching = await matchJobs(db, organisationId, { accounting, now, staleDays: settings.staleDays });
|
|
429
441
|
}
|
|
442
|
+
if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, now });
|
|
430
443
|
return summary;
|
|
431
444
|
}
|
|
432
445
|
|