@arcforgelabs/flow 2026.10.37 → 2026.10.39
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 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +27 -9
- package/server/api/invoicing.js +198 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +89 -3
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +109 -8
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +6 -2
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +42 -12
- package/server/integrations/xero/routes.js +66 -7
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +89 -3
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +326 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +9 -7
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +189 -31
- package/server/ui/jobs.js +738 -70
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CUSTOM_FIELD_TYPES, integrationSettingEvents, integrationSettings, jobFieldEvents, jobFields, jobFieldSettings, jobFieldValues, people,
|
|
5
|
+
} from '../db/schema.js';
|
|
4
6
|
import { FlowError, notFound } from './errors.js';
|
|
5
7
|
|
|
6
8
|
// Custom fields (docs/CUSTOM_FIELDS.md): fields a lab makes for its jobs in
|
|
@@ -14,6 +16,14 @@ export { CUSTOM_FIELD_TYPES };
|
|
|
14
16
|
// person, or yes and no.
|
|
15
17
|
export const GROUPABLE_TYPES = ['dropdown', 'person', 'checkbox'];
|
|
16
18
|
export const OPTION_COLORS = ['gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink'];
|
|
19
|
+
// Each type group's own colour, until the lab picks another.
|
|
20
|
+
export const TYPE_COLORS = { dropdown: 'green', person: 'blue', checkbox: 'purple', date: 'teal', number: 'amber', text: 'gray' };
|
|
21
|
+
|
|
22
|
+
function readColor(raw, what) {
|
|
23
|
+
if (raw === null) return null;
|
|
24
|
+
if (!OPTION_COLORS.includes(raw)) throw invalid(`${what} must be one of ${OPTION_COLORS.join(', ')}`);
|
|
25
|
+
return raw;
|
|
26
|
+
}
|
|
17
27
|
|
|
18
28
|
const MAX_FIELDS = 50;
|
|
19
29
|
const MAX_OPTIONS = 50;
|
|
@@ -74,6 +84,7 @@ export function readField(raw, { partial = false, previous = null } = {}) {
|
|
|
74
84
|
} else if (input.type !== undefined && input.type !== previous?.type) {
|
|
75
85
|
throw invalid('the type of a field cannot change; make a new field instead');
|
|
76
86
|
}
|
|
87
|
+
if (input.color !== undefined) out.color = readColor(input.color, 'color');
|
|
77
88
|
const type = out.type ?? previous?.type;
|
|
78
89
|
if (type === 'dropdown' && (!partial || input.options !== undefined)) out.options = readOptions(input.options, previous?.options ?? []);
|
|
79
90
|
else if (type !== 'dropdown' && input.options !== undefined && input.options !== null && !(Array.isArray(input.options) && !input.options.length)) {
|
|
@@ -87,6 +98,7 @@ export const publicField = (row) => ({
|
|
|
87
98
|
name: row.name,
|
|
88
99
|
type: row.type,
|
|
89
100
|
options: row.options ?? [],
|
|
101
|
+
color: row.color ?? null,
|
|
90
102
|
position: row.position,
|
|
91
103
|
groupable: GROUPABLE_TYPES.includes(row.type),
|
|
92
104
|
archived: Boolean(row.archivedAt),
|
|
@@ -95,11 +107,15 @@ export const publicField = (row) => ({
|
|
|
95
107
|
updatedAt: row.updatedAt?.toISOString?.() ?? row.updatedAt,
|
|
96
108
|
});
|
|
97
109
|
|
|
110
|
+
// `valueCount` is how many jobs have a value, so Delete can say what goes.
|
|
98
111
|
export async function listFields(db, organisationId, { archived = false } = {}) {
|
|
99
112
|
const where = [eq(jobFields.organisationId, organisationId)];
|
|
100
113
|
if (!archived) where.push(isNull(jobFields.archivedAt));
|
|
101
|
-
const rows = await db.select(
|
|
102
|
-
|
|
114
|
+
const rows = await db.select({
|
|
115
|
+
field: jobFields,
|
|
116
|
+
valueCount: sql`(select count(*) from ${jobFieldValues} v where v.organisation_id = ${jobFields.organisationId} and v.field_id = ${jobFields.id})`.mapWith(Number),
|
|
117
|
+
}).from(jobFields).where(and(...where)).orderBy(asc(jobFields.position), asc(jobFields.createdAt));
|
|
118
|
+
return rows.map(({ field, valueCount }) => ({ ...publicField(field), valueCount }));
|
|
103
119
|
}
|
|
104
120
|
|
|
105
121
|
async function readRow(db, organisationId, id) {
|
|
@@ -133,6 +149,66 @@ export async function createField(db, organisationId, raw, { personId = null } =
|
|
|
133
149
|
});
|
|
134
150
|
}
|
|
135
151
|
|
|
152
|
+
// Every lab gets an Assignee: a person field, made once, the first time
|
|
153
|
+
// the lab's fields are read and it has no person field at all (live or
|
|
154
|
+
// archived). The lab can then rename, archive or delete it like any field,
|
|
155
|
+
// and Flow does not make it again: job_field_settings.assignee_made_at
|
|
156
|
+
// records that it was made (or that the lab already had a person field).
|
|
157
|
+
// A field already named Assignee of another type is left alone.
|
|
158
|
+
export const ASSIGNEE = { name: 'Assignee', type: 'person' };
|
|
159
|
+
export async function ensureAssignee(db, organisationId) {
|
|
160
|
+
const [settings] = await db.select({ at: jobFieldSettings.assigneeMadeAt }).from(jobFieldSettings)
|
|
161
|
+
.where(eq(jobFieldSettings.organisationId, organisationId));
|
|
162
|
+
if (settings?.at) return null;
|
|
163
|
+
// Only the request that marks it makes it.
|
|
164
|
+
const [marked] = await db.insert(jobFieldSettings).values({ organisationId, assigneeMadeAt: new Date() })
|
|
165
|
+
.onConflictDoUpdate({ target: jobFieldSettings.organisationId, set: { assigneeMadeAt: new Date() }, setWhere: isNull(jobFieldSettings.assigneeMadeAt) })
|
|
166
|
+
.returning({ organisationId: jobFieldSettings.organisationId });
|
|
167
|
+
if (!marked) return null;
|
|
168
|
+
const [{ count }] = await db.select({ count: sql`count(*)`.mapWith(Number) }).from(jobFields)
|
|
169
|
+
.where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.type, 'person')));
|
|
170
|
+
if (count > 0) return null;
|
|
171
|
+
try {
|
|
172
|
+
return await createField(db, organisationId, ASSIGNEE);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error.code === 'conflict' || error.code === 'invalid' || error.code === '23505') return null;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Each type group's colour: TYPE_COLORS, with the lab's own choices on top.
|
|
180
|
+
export async function readGroupColors(db, organisationId) {
|
|
181
|
+
const [row] = await db.select({ colors: jobFieldSettings.groupColors }).from(jobFieldSettings)
|
|
182
|
+
.where(eq(jobFieldSettings.organisationId, organisationId));
|
|
183
|
+
const own = row?.colors ?? {};
|
|
184
|
+
return Object.fromEntries(CUSTOM_FIELD_TYPES.map((t) => [t, OPTION_COLORS.includes(own[t]) ? own[t] : TYPE_COLORS[t]]));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Give one type group a colour. null puts back its own colour.
|
|
188
|
+
export async function setGroupColor(db, organisationId, type, raw, { personId = null } = {}) {
|
|
189
|
+
if (!CUSTOM_FIELD_TYPES.includes(type)) throw notFound('group');
|
|
190
|
+
const color = raw === undefined ? undefined : readColor(raw, 'color');
|
|
191
|
+
if (color === undefined) throw invalid('color is required (null puts back the group\'s own colour)');
|
|
192
|
+
const own = { ...(await db.select({ colors: jobFieldSettings.groupColors }).from(jobFieldSettings)
|
|
193
|
+
.where(eq(jobFieldSettings.organisationId, organisationId)))[0]?.colors };
|
|
194
|
+
if (color === null || color === TYPE_COLORS[type]) delete own[type]; else own[type] = color;
|
|
195
|
+
await db.insert(jobFieldSettings).values({ organisationId, groupColors: own, updatedBy: personId })
|
|
196
|
+
.onConflictDoUpdate({ target: jobFieldSettings.organisationId, set: { groupColors: own, updatedBy: personId, updatedAt: new Date() } });
|
|
197
|
+
return readGroupColors(db, organisationId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// When an integration was connected: the first time its settings were saved
|
|
201
|
+
// or it was turned on in Flow, or, for one set up in the host's own
|
|
202
|
+
// settings, the first time Flow saw one of its records (`seenSince`).
|
|
203
|
+
export async function integrationConnectedAt(db, adapterId, seenSince = null) {
|
|
204
|
+
const [[saved], [event]] = await Promise.all([
|
|
205
|
+
db.select({ at: sql`min(${integrationSettings.createdAt})` }).from(integrationSettings).where(eq(integrationSettings.adapterId, adapterId)),
|
|
206
|
+
db.select({ at: sql`min(${integrationSettingEvents.createdAt})` }).from(integrationSettingEvents).where(eq(integrationSettingEvents.adapterId, adapterId)),
|
|
207
|
+
]);
|
|
208
|
+
const times = [saved?.at, event?.at, seenSince].filter(Boolean).map((t) => new Date(t)).filter((t) => !Number.isNaN(t.getTime()));
|
|
209
|
+
return times.length ? new Date(Math.min(...times)).toISOString() : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
136
212
|
export async function updateField(db, organisationId, id, raw, { personId = null } = {}) {
|
|
137
213
|
const previous = await readRow(db, organisationId, id);
|
|
138
214
|
if (previous.archivedAt) throw new FlowError('conflict', 'the field is archived; bring it back first');
|
|
@@ -153,6 +229,16 @@ export async function archiveField(db, organisationId, id, { archived = true, pe
|
|
|
153
229
|
return publicField(row);
|
|
154
230
|
}
|
|
155
231
|
|
|
232
|
+
// Delete a field for good, with every job's value and its history (the
|
|
233
|
+
// tables cascade). Archive is the step that keeps them.
|
|
234
|
+
export async function deleteField(db, organisationId, id) {
|
|
235
|
+
const field = await readRow(db, organisationId, id);
|
|
236
|
+
const [{ count }] = await db.select({ count: sql`count(*)`.mapWith(Number) }).from(jobFieldValues)
|
|
237
|
+
.where(and(eq(jobFieldValues.organisationId, organisationId), eq(jobFieldValues.fieldId, field.id)));
|
|
238
|
+
await db.delete(jobFields).where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, field.id)));
|
|
239
|
+
return { id: field.id, name: field.name, valuesDeleted: count };
|
|
240
|
+
}
|
|
241
|
+
|
|
156
242
|
// Put the fields in this order. `ids` lists every field that is not archived.
|
|
157
243
|
export async function orderFields(db, organisationId, ids, { personId = null } = {}) {
|
|
158
244
|
if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string')) throw invalid('ids must be a list of field ids');
|
package/server/core/job-board.js
CHANGED
|
@@ -102,7 +102,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
|
|
|
102
102
|
if (status && !statuses.has(status)) statuses.set(status, { label: status, final: false, firstSeenAt: null });
|
|
103
103
|
return {
|
|
104
104
|
sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, status,
|
|
105
|
-
clientName: o.contactName, workClientName: null, practitioner: o.practitioner, department: null,
|
|
105
|
+
clientName: o.contactName, workClientName: null, practitioner: o.practitioner, patient: null, department: null,
|
|
106
106
|
orderedOn: null, dueOn: null, jobDate: o.jobDate, shippedOn: o.shippedOn,
|
|
107
107
|
totalCents: o.totalCents, lines: ledgerLines(o.lines), obligation: o,
|
|
108
108
|
};
|
|
@@ -129,6 +129,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
|
|
|
129
129
|
workClientName: item.clientName ?? null,
|
|
130
130
|
practitioner: item.practitioner ?? o?.practitioner ?? null,
|
|
131
131
|
department: item.department ?? null,
|
|
132
|
+
patient: item.patient ?? null,
|
|
132
133
|
orderedOn: item.orderedOn ?? null,
|
|
133
134
|
dueOn: item.dueOn ?? null,
|
|
134
135
|
jobDate: o?.jobDate ?? null,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { and, asc, desc, eq, ilike, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { billingObligations, contacts, invoiceLines, invoices, priceItems } from '../db/schema.js';
|
|
3
|
+
import { notFound } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// The Reconcile page (docs/JOB_INVOICE_LINKS.md, "Reconcile"): Flow's own
|
|
6
|
+
// jobs against Flow's own invoices. A job that is ready to invoice and has
|
|
7
|
+
// no Flow invoice, and a Flow invoice that no job is linked to, are gaps.
|
|
8
|
+
// For one of them it lists the possible other side, each fact compared. It
|
|
9
|
+
// only reads; linkJobToInvoice and confirmNoInvoice (ledger.js) write.
|
|
10
|
+
// Matching a Flow invoice to one in an accounting system is that
|
|
11
|
+
// integration's own work, not this page's.
|
|
12
|
+
|
|
13
|
+
// A job with no Flow invoice that should have one: ready, not held, and not
|
|
14
|
+
// tied to an accounting invoice by the earlier matcher.
|
|
15
|
+
const JOB_GAP = and(
|
|
16
|
+
isNotNull(billingObligations.readyAt),
|
|
17
|
+
isNull(billingObligations.invoiceId),
|
|
18
|
+
isNull(billingObligations.accountingRef),
|
|
19
|
+
ne(billingObligations.holdState, 'held'),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const hasJob = sql`exists (select 1 from billing_obligations o
|
|
23
|
+
where o.organisation_id = ${invoices.organisationId} and o.invoice_id = ${invoices.id})`;
|
|
24
|
+
|
|
25
|
+
const SAME = 'same';
|
|
26
|
+
const DIFFERS = 'differs';
|
|
27
|
+
const daysApart = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000);
|
|
28
|
+
const norm = (text) => String(text ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
29
|
+
const WINDOW_DAYS = 60;
|
|
30
|
+
// An invoice's date: its issue date, or the day a draft was made.
|
|
31
|
+
const dateOf = (i) => i.issueDate ?? (i.createdAt ? new Date(i.createdAt).toISOString().slice(0, 10) : null);
|
|
32
|
+
const SHOWN = 3;
|
|
33
|
+
const SEARCHED = 8;
|
|
34
|
+
|
|
35
|
+
// One fact against the other side's: `same`, `differs`, or null when either
|
|
36
|
+
// side does not know.
|
|
37
|
+
function compare(ours, theirs) {
|
|
38
|
+
if (ours == null || ours === '' || theirs == null || theirs === '') return null;
|
|
39
|
+
return norm(ours) === norm(theirs) ? SAME : DIFFERS;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Two sets of lines, the same price items and amounts in any order.
|
|
43
|
+
function compareLines(a, b) {
|
|
44
|
+
if (!a.length || !b.length) return null;
|
|
45
|
+
const key = (l) => `${l.priceItemId ?? norm(l.code)}|${l.amountCents ?? ''}`;
|
|
46
|
+
return a.map(key).sort().join(',') === b.map(key).sort().join(',') ? SAME : DIFFERS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A job line's amount before tax.
|
|
50
|
+
function lineCents(line) {
|
|
51
|
+
if (line.lineAmountCents != null) return line.lineAmountCents;
|
|
52
|
+
if (line.unitAmountCents == null) return null;
|
|
53
|
+
return line.unitAmountCents * (line.quantity ?? 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every fact of one candidate against the record it may belong to. `a` and
|
|
57
|
+
// `b` are { contactId, reference, date, lines, totalCents }.
|
|
58
|
+
function checksOf(a, b, otherCount) {
|
|
59
|
+
return {
|
|
60
|
+
client: a.contactId && b.contactId ? (a.contactId === b.contactId ? SAME : DIFFERS) : null,
|
|
61
|
+
reference: compare(a.reference, b.reference),
|
|
62
|
+
date: a.date && b.date ? { days: daysApart(a.date, b.date) } : null,
|
|
63
|
+
lines: compareLines(a.lines, b.lines),
|
|
64
|
+
total: a.totalCents == null || b.totalCents == null ? null : a.totalCents === b.totalCents ? SAME : DIFFERS,
|
|
65
|
+
others: otherCount,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Higher is a better match: the total, the lines, the Reference, the date,
|
|
70
|
+
// and not taken by another record.
|
|
71
|
+
function scoreOf(c) {
|
|
72
|
+
return (c.total === SAME ? 4 : 0) + (c.lines === SAME ? 3 : 0) + (c.reference === SAME ? 3 : 0) + (c.client === SAME ? 2 : 0)
|
|
73
|
+
+ (c.date && Math.abs(c.date.days) <= 3 ? 1 : 0) - (c.others ? 2 : 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function codesFor(db, organisationId, ids) {
|
|
77
|
+
if (!ids.length) return new Map();
|
|
78
|
+
const rows = await db.select({ id: priceItems.id, code: priceItems.code }).from(priceItems)
|
|
79
|
+
.where(and(eq(priceItems.organisationId, organisationId), inArray(priceItems.id, ids)));
|
|
80
|
+
return new Map(rows.map((r) => [r.id, r.code]));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function jobLines(o, codes) {
|
|
84
|
+
return (o.lines ?? []).map((l) => ({
|
|
85
|
+
priceItemId: l.priceItemId ?? null, code: l.itemCode ?? codes.get(l.priceItemId) ?? null, description: l.description ?? null, amountCents: lineCents(l),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The two queues, oldest first. jobs: { id, clientName, practitioner,
|
|
90
|
+
// jobDate, totalCents, possible }; invoices: { id, number, clientName,
|
|
91
|
+
// issueDate, status, totalCents, possible }. `possible` counts the other
|
|
92
|
+
// side with the same client and total.
|
|
93
|
+
export async function listReconcile(db, organisationId) {
|
|
94
|
+
const jobs = await db.select({
|
|
95
|
+
id: billingObligations.id,
|
|
96
|
+
clientName: contacts.name,
|
|
97
|
+
practitioner: billingObligations.practitioner,
|
|
98
|
+
jobDate: billingObligations.jobDate,
|
|
99
|
+
totalCents: billingObligations.totalCents,
|
|
100
|
+
possible: sql`(select count(*) from invoices i
|
|
101
|
+
where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
|
|
102
|
+
and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
|
|
103
|
+
and not exists (select 1 from billing_obligations x where x.organisation_id = i.organisation_id and x.invoice_id = i.id))`.mapWith(Number),
|
|
104
|
+
}).from(billingObligations)
|
|
105
|
+
.leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
|
|
106
|
+
.where(and(eq(billingObligations.organisationId, organisationId), JOB_GAP))
|
|
107
|
+
.orderBy(sql`${billingObligations.jobDate} asc nulls last`, asc(billingObligations.createdAt));
|
|
108
|
+
const lonely = await db.select({
|
|
109
|
+
id: invoices.id,
|
|
110
|
+
number: invoices.number,
|
|
111
|
+
clientName: contacts.name,
|
|
112
|
+
issueDate: sql`coalesce(${invoices.issueDate}, ${invoices.createdAt}::date)::text`,
|
|
113
|
+
status: invoices.status,
|
|
114
|
+
totalCents: invoices.totalCents,
|
|
115
|
+
possible: sql`(select count(*) from billing_obligations o
|
|
116
|
+
where o.organisation_id = ${invoices.organisationId} and o.contact_id = ${invoices.contactId}
|
|
117
|
+
and o.invoice_id is null and o.accounting_ref is null and o.total_cents = ${invoices.totalCents})`.mapWith(Number),
|
|
118
|
+
}).from(invoices)
|
|
119
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
120
|
+
.where(and(eq(invoices.organisationId, organisationId), ne(invoices.status, 'void'), sql`not ${hasJob}`))
|
|
121
|
+
.orderBy(sql`${invoices.issueDate} asc nulls last`, asc(invoices.createdAt));
|
|
122
|
+
return { jobs, invoices: lonely };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// One job and the Flow invoices that may be its own, best first. With `q`,
|
|
126
|
+
// any Flow invoice whose number, client, Reference or total matches.
|
|
127
|
+
// `reference` is the Reference Flow would give the job's invoice.
|
|
128
|
+
export async function jobMatchDetail(db, organisationId, obligationId, { reference = null, q = '' } = {}) {
|
|
129
|
+
const [row] = await db.select({ obligation: billingObligations, clientName: contacts.name })
|
|
130
|
+
.from(billingObligations)
|
|
131
|
+
.leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
|
|
132
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
133
|
+
if (!row) throw notFound('job');
|
|
134
|
+
const o = row.obligation;
|
|
135
|
+
const codes = await codesFor(db, organisationId, (o.lines ?? []).map((l) => l.priceItemId).filter(Boolean));
|
|
136
|
+
const job = {
|
|
137
|
+
id: o.id,
|
|
138
|
+
clientName: row.clientName,
|
|
139
|
+
practitioner: o.practitioner,
|
|
140
|
+
reference,
|
|
141
|
+
jobDate: o.jobDate,
|
|
142
|
+
sourceSystem: o.sourceSystem,
|
|
143
|
+
sourceJobRef: o.sourceJobRef,
|
|
144
|
+
caseRef: o.caseRef,
|
|
145
|
+
sourceStatus: o.sourceStatus,
|
|
146
|
+
lines: jobLines(o, codes),
|
|
147
|
+
totalCents: o.totalCents,
|
|
148
|
+
open: !o.invoiceId && !o.accountingRef,
|
|
149
|
+
// An unclear accounting match: "Make invoice" settles it first.
|
|
150
|
+
unclear: o.accountingState === 'ambiguous',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const search = String(q ?? '').trim();
|
|
154
|
+
const where = [eq(invoices.organisationId, organisationId), ne(invoices.status, 'void')];
|
|
155
|
+
if (search) where.push(searchInvoices(search));
|
|
156
|
+
else {
|
|
157
|
+
if (!o.contactId) return { job, invoices: [] };
|
|
158
|
+
where.push(eq(invoices.contactId, o.contactId));
|
|
159
|
+
if (o.jobDate) where.push(sql`abs(coalesce(${invoices.issueDate}, ${invoices.createdAt}::date) - ${o.jobDate}::date) <= ${WINDOW_DAYS}`);
|
|
160
|
+
}
|
|
161
|
+
const found = await db.select({ invoice: invoices, clientName: contacts.name }).from(invoices)
|
|
162
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
163
|
+
.where(and(...where)).orderBy(desc(invoices.createdAt)).limit(50);
|
|
164
|
+
const detail = await invoiceFacts(db, organisationId, found.map((f) => f.invoice));
|
|
165
|
+
const ours = { contactId: o.contactId, reference, date: o.jobDate, lines: job.lines, totalCents: o.totalCents };
|
|
166
|
+
const ranked = found.map(({ invoice, clientName }) => {
|
|
167
|
+
const d = detail.get(invoice.id);
|
|
168
|
+
const checks = checksOf(ours, { contactId: invoice.contactId, reference: invoice.reference, date: dateOf(invoice), lines: d.lines, totalCents: invoice.totalCents }, d.jobs.length);
|
|
169
|
+
return {
|
|
170
|
+
id: invoice.id, number: invoice.number, clientName, issueDate: invoice.issueDate, date: dateOf(invoice), status: invoice.status,
|
|
171
|
+
reference: invoice.reference, lines: d.lines, totalCents: invoice.totalCents, jobs: d.jobs, checks, score: scoreOf(checks),
|
|
172
|
+
};
|
|
173
|
+
}).sort((a, b) => b.score - a.score).slice(0, search ? SEARCHED : SHOWN);
|
|
174
|
+
return { job, invoices: ranked.map((c, index) => ({ ...c, best: index === 0 && c.score > 0 })) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// One Flow invoice with no job, and the jobs with no invoice that may be its
|
|
178
|
+
// own, best first.
|
|
179
|
+
export async function invoiceMatchDetail(db, organisationId, invoiceId, { q = '' } = {}) {
|
|
180
|
+
const [row] = await db.select({ invoice: invoices, clientName: contacts.name }).from(invoices)
|
|
181
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
182
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId)));
|
|
183
|
+
if (!row) throw notFound('invoice');
|
|
184
|
+
const i = row.invoice;
|
|
185
|
+
const d = (await invoiceFacts(db, organisationId, [i])).get(i.id);
|
|
186
|
+
const invoice = {
|
|
187
|
+
id: i.id, number: i.number, clientName: row.clientName, issueDate: i.issueDate, date: dateOf(i), status: i.status, reference: i.reference,
|
|
188
|
+
lines: d.lines, totalCents: i.totalCents, jobs: d.jobs, open: d.jobs.length === 0 && i.status !== 'void',
|
|
189
|
+
};
|
|
190
|
+
const search = String(q ?? '').trim();
|
|
191
|
+
const where = [eq(billingObligations.organisationId, organisationId), isNull(billingObligations.invoiceId), isNull(billingObligations.accountingRef)];
|
|
192
|
+
if (search) {
|
|
193
|
+
const like = `%${search.replace(/[%_\\]/g, '\\$&')}%`;
|
|
194
|
+
where.push(or(ilike(contacts.name, like), ilike(billingObligations.practitioner, like), ilike(billingObligations.caseRef, like), ilike(billingObligations.sourceJobRef, like)));
|
|
195
|
+
} else {
|
|
196
|
+
where.push(eq(billingObligations.contactId, i.contactId));
|
|
197
|
+
if (invoice.date) where.push(sql`abs(coalesce(${billingObligations.jobDate}, ${billingObligations.createdAt}::date) - ${invoice.date}::date) <= ${WINDOW_DAYS}`);
|
|
198
|
+
}
|
|
199
|
+
const found = await db.select({ obligation: billingObligations, clientName: contacts.name }).from(billingObligations)
|
|
200
|
+
.leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
|
|
201
|
+
.where(and(...where)).orderBy(desc(billingObligations.createdAt)).limit(50);
|
|
202
|
+
const codes = await codesFor(db, organisationId, found.flatMap((f) => (f.obligation.lines ?? []).map((l) => l.priceItemId)).filter(Boolean));
|
|
203
|
+
const theirs = { contactId: i.contactId, reference: i.reference, date: invoice.date, lines: d.lines, totalCents: i.totalCents };
|
|
204
|
+
const ranked = found.map(({ obligation: o, clientName }) => {
|
|
205
|
+
const lines = jobLines(o, codes);
|
|
206
|
+
const checks = checksOf(theirs, { contactId: o.contactId, reference: null, date: o.jobDate, lines, totalCents: o.totalCents }, 0);
|
|
207
|
+
return {
|
|
208
|
+
id: o.id, clientName, practitioner: o.practitioner, jobDate: o.jobDate, caseRef: o.caseRef, sourceStatus: o.sourceStatus,
|
|
209
|
+
sourceSystem: o.sourceSystem, lines, totalCents: o.totalCents, ready: Boolean(o.readyAt), checks, score: scoreOf(checks),
|
|
210
|
+
};
|
|
211
|
+
}).sort((a, b) => b.score - a.score).slice(0, search ? SEARCHED : SHOWN);
|
|
212
|
+
return { invoice, jobs: ranked.map((c, index) => ({ ...c, best: index === 0 && c.score > 0 })) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A search box's words against Flow invoices: number, client, Reference, or
|
|
216
|
+
// a total in dollars ("150" or "$150.00").
|
|
217
|
+
function searchInvoices(search) {
|
|
218
|
+
const like = `%${search.replace(/[%_\\]/g, '\\$&')}%`;
|
|
219
|
+
const terms = [ilike(invoices.number, like), ilike(contacts.name, like), ilike(invoices.reference, like)];
|
|
220
|
+
const amount = /^\$?\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)$/.exec(search);
|
|
221
|
+
if (amount) terms.push(eq(invoices.totalCents, Math.round(Number(amount[1].replace(/,/g, '')) * 100)));
|
|
222
|
+
return or(...terms);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Each invoice's lines (code and amount before tax) and the jobs linked to it.
|
|
226
|
+
async function invoiceFacts(db, organisationId, rows) {
|
|
227
|
+
const ids = rows.map((r) => r.id);
|
|
228
|
+
const facts = new Map(ids.map((id) => [id, { lines: [], jobs: [] }]));
|
|
229
|
+
if (!ids.length) return facts;
|
|
230
|
+
const lines = await db.select({ invoiceId: invoiceLines.invoiceId, priceItemId: invoiceLines.priceItemId, code: priceItems.code, description: invoiceLines.description, amountCents: invoiceLines.lineAmountCents })
|
|
231
|
+
.from(invoiceLines)
|
|
232
|
+
.leftJoin(priceItems, eq(priceItems.id, invoiceLines.priceItemId))
|
|
233
|
+
.where(and(eq(invoiceLines.organisationId, organisationId), inArray(invoiceLines.invoiceId, ids)))
|
|
234
|
+
.orderBy(asc(invoiceLines.position));
|
|
235
|
+
for (const { invoiceId, ...line } of lines) facts.get(invoiceId).lines.push(line);
|
|
236
|
+
const jobs = await db.select({ id: billingObligations.id, invoiceId: billingObligations.invoiceId, jobDate: billingObligations.jobDate, practitioner: billingObligations.practitioner })
|
|
237
|
+
.from(billingObligations)
|
|
238
|
+
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
|
|
239
|
+
for (const { invoiceId, ...job } of jobs) facts.get(invoiceId).jobs.push(job);
|
|
240
|
+
return facts;
|
|
241
|
+
}
|
package/server/core/ledger.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import {
|
|
4
|
-
billingObligations, invoices, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
|
|
4
|
+
billingObligations, invoices, linkRejections, linkSuggestions, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
|
|
5
5
|
} from '../db/schema.js';
|
|
6
6
|
import { FlowError, notFound } from './errors.js';
|
|
7
7
|
import { createDraftInvoice, getInvoice, updateDraftInvoice } from './invoices.js';
|
|
@@ -305,20 +305,21 @@ function changedReview(obligation, compare) {
|
|
|
305
305
|
|
|
306
306
|
// "Use new lines": a person takes the work system's new lines (and its
|
|
307
307
|
// customer, when that changed) into Flow's own unsent draft. Flow writes only
|
|
308
|
-
// its own draft, never another system. Refused when the
|
|
309
|
-
//
|
|
310
|
-
|
|
308
|
+
// its own draft, never another system. Refused when the invoice has left
|
|
309
|
+
// Flow. When the draft changed too, `accept` (a person's "Accept change")
|
|
310
|
+
// takes the work system's value over the draft's; without it, refused.
|
|
311
|
+
export async function useSourceLines(db, organisationId, obligationId, { actor, accept = false }) {
|
|
311
312
|
requireActor(actor);
|
|
312
313
|
return db.transaction(async (tx) => {
|
|
313
314
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
314
315
|
if (!obligation.invoiceId) throw new FlowError('invalid', 'the job has no invoice');
|
|
315
316
|
const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
|
|
316
|
-
if (compare.outcome === 'conflict') {
|
|
317
|
-
throw new FlowError('conflict', compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
|
|
317
|
+
if (compare.outcome === 'conflict' && (compare.sent || compare.missing || !accept)) {
|
|
318
|
+
throw new FlowError('conflict', compare.missing ? 'the invoice was not found' : compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
|
|
318
319
|
}
|
|
319
320
|
const changes = {};
|
|
320
321
|
for (const { field, changedIn } of compare.fields) {
|
|
321
|
-
if (changedIn !== 'work') continue;
|
|
322
|
+
if (changedIn !== 'work' && !(accept && changedIn === 'both')) continue;
|
|
322
323
|
if (field === 'lines') {
|
|
323
324
|
changes.lines = obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
|
|
324
325
|
{ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
|
|
@@ -338,12 +339,30 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
|
|
|
338
339
|
const fields = { draftBasis: basisOf(obligation) };
|
|
339
340
|
if (obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON) Object.assign(fields, { holdState: 'none', holdReason: null });
|
|
340
341
|
const row = await update(tx, obligation, fields);
|
|
341
|
-
await addEvent(tx, row, 'source_lines_used', actor, { invoiceId: invoice.id, changed: Object.keys(changes), totalCents: invoice.totalCents });
|
|
342
|
+
await addEvent(tx, row, 'source_lines_used', actor, { invoiceId: invoice.id, changed: Object.keys(changes), totalCents: invoice.totalCents, ...(accept ? { accepted: compare.outcome } : {}) });
|
|
342
343
|
await resolveReviewFor(tx, organisationId, { kind: REVIEW_CHANGED, entity: 'obligation', flowId: row.id, resolution: 'used_new_lines', actor });
|
|
343
344
|
return { obligation: row, invoice };
|
|
344
345
|
});
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
// After a person rechecks a job (POST /jobs/:id/recheck): a "job changed"
|
|
349
|
+
// hold whose job now matches the draft again (the work system was fixed, or
|
|
350
|
+
// the draft was) is over. Answers the obligation.
|
|
351
|
+
export async function settleJobChange(db, organisationId, obligationId, { actor }) {
|
|
352
|
+
requireActor(actor);
|
|
353
|
+
return db.transaction(async (tx) => {
|
|
354
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
355
|
+
if (!obligation.invoiceId || obligation.holdState !== 'review' || obligation.holdReason !== CHANGED_REASON) return obligation;
|
|
356
|
+
const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
|
|
357
|
+
const settled = compare.fields.every(({ changedIn }) => ['none', 'same', 'flow'].includes(changedIn)) && !compare.missing;
|
|
358
|
+
if (!settled) return obligation;
|
|
359
|
+
const row = await update(tx, obligation, { holdState: 'none', holdReason: null, draftBasis: basisOf(obligation) });
|
|
360
|
+
await addEvent(tx, row, 'review_settled', actor, { invoiceId: obligation.invoiceId });
|
|
361
|
+
await resolveReviewFor(tx, organisationId, { kind: REVIEW_CHANGED, entity: 'obligation', flowId: row.id, resolution: 'job_matches_invoice', actor });
|
|
362
|
+
return row;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
347
366
|
// What changed in a job after its invoice was made, for the invoice page:
|
|
348
367
|
// each line as it was (the draft basis) and as the work system has it now.
|
|
349
368
|
// Lines pair by price item, else item code, account or description; an
|
|
@@ -411,6 +430,9 @@ export async function jobChangeFor(db, organisationId, invoiceId) {
|
|
|
411
430
|
outcome: compare.outcome,
|
|
412
431
|
sent: compare.sent,
|
|
413
432
|
canAdopt: compare.outcome === 'offer',
|
|
433
|
+
// "Accept change": the draft takes the work system's values, even when
|
|
434
|
+
// the draft changed too. Not once the invoice has left Flow.
|
|
435
|
+
canAccept: !compare.sent && !compare.missing,
|
|
414
436
|
lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
|
|
415
437
|
contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
|
|
416
438
|
total: { was: was.totalCents ?? null, now: obligation.totalCents },
|
|
@@ -668,6 +690,85 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
|
|
|
668
690
|
});
|
|
669
691
|
}
|
|
670
692
|
|
|
693
|
+
// A job with no invoice of its own whose accounting evidence is ambiguous:
|
|
694
|
+
// the matcher found accounting invoices that may be this job's, but none
|
|
695
|
+
// exactly. A person settles it on the Reconcile page: "Make invoice", or a
|
|
696
|
+
// link to a Flow invoice (linkJobToInvoice).
|
|
697
|
+
const unlinked = (o) => o.accountingState === 'ambiguous' && !o.invoiceId && !o.accountingRef;
|
|
698
|
+
|
|
699
|
+
// The matcher's open suggestions for one job, best first.
|
|
700
|
+
export async function linkSuggestionsFor(db, organisationId, obligationId) {
|
|
701
|
+
return db.select({
|
|
702
|
+
system: linkSuggestions.system, externalId: linkSuggestions.externalId, externalNumber: linkSuggestions.externalNumber,
|
|
703
|
+
score: linkSuggestions.score, signals: linkSuggestions.signals,
|
|
704
|
+
}).from(linkSuggestions).where(and(
|
|
705
|
+
eq(linkSuggestions.organisationId, organisationId),
|
|
706
|
+
eq(linkSuggestions.obligationId, obligationId),
|
|
707
|
+
eq(linkSuggestions.state, 'open'),
|
|
708
|
+
)).orderBy(asc(linkSuggestions.rank));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Rejects every open suggestion for the job ("not this one"): the matcher
|
|
712
|
+
// never suggests those pairs again.
|
|
713
|
+
async function rejectSuggestions(tx, obligation, { actor }) {
|
|
714
|
+
const open = await tx.select().from(linkSuggestions).where(and(
|
|
715
|
+
eq(linkSuggestions.organisationId, obligation.organisationId),
|
|
716
|
+
eq(linkSuggestions.obligationId, obligation.id),
|
|
717
|
+
eq(linkSuggestions.state, 'open'),
|
|
718
|
+
));
|
|
719
|
+
for (const suggestion of open) {
|
|
720
|
+
await tx.update(linkSuggestions).set({ state: 'rejected', decidedBy: actor, decidedAt: sql`now()`, updatedAt: sql`now()` })
|
|
721
|
+
.where(eq(linkSuggestions.id, suggestion.id));
|
|
722
|
+
await tx.insert(linkRejections)
|
|
723
|
+
.values({ organisationId: obligation.organisationId, obligationId: obligation.id, system: suggestion.system, externalId: suggestion.externalId, actor })
|
|
724
|
+
.onConflictDoNothing();
|
|
725
|
+
}
|
|
726
|
+
return open.map((s) => s.externalId);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// "Make invoice": a person says the job has no invoice yet. Every suggestion
|
|
730
|
+
// is rejected and the job is recorded as not found, so it can draft. The
|
|
731
|
+
// live check before the draft still runs (draftInvoiceForObligation).
|
|
732
|
+
export async function confirmNoInvoice(db, organisationId, obligationId, { actor }) {
|
|
733
|
+
requireActor(actor);
|
|
734
|
+
return db.transaction(async (tx) => {
|
|
735
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
736
|
+
if (!unlinked(obligation)) throw new FlowError('invalid', 'the job is not waiting for a person to settle its invoice');
|
|
737
|
+
const rejected = await rejectSuggestions(tx, obligation, { actor });
|
|
738
|
+
const row = await update(tx, obligation, { accountingState: 'not_found', accountingCheckedAt: sql`now()` });
|
|
739
|
+
await addEvent(tx, row, 'accounting_checked', actor, { reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected });
|
|
740
|
+
return row;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// A person links a job to a Flow invoice that already exists (Reconcile).
|
|
745
|
+
// The job then has its invoice: Flow drafts no other. A job held on unclear
|
|
746
|
+
// accounting evidence is settled by it; whether the Flow invoice matches one
|
|
747
|
+
// in the accounting system is that integration's own work, not the job's.
|
|
748
|
+
// `accountingRef` is given by an accounting integration that made the Flow
|
|
749
|
+
// invoice from the accounting invoice the job is tied to already.
|
|
750
|
+
export async function linkJobToInvoice(db, organisationId, obligationId, { invoiceId, accountingRef = null, actor }) {
|
|
751
|
+
requireActor(actor);
|
|
752
|
+
return db.transaction(async (tx) => {
|
|
753
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
754
|
+
if (obligation.invoiceId) throw new FlowError('invalid', 'the job has an invoice already');
|
|
755
|
+
if (obligation.accountingRef && obligation.accountingRef !== accountingRef) throw new FlowError('invalid', 'the job is linked to an accounting invoice already');
|
|
756
|
+
const [invoice] = await tx.select({ id: invoices.id, status: invoices.status, contactId: invoices.contactId }).from(invoices)
|
|
757
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId)));
|
|
758
|
+
if (!invoice) throw notFound('invoice');
|
|
759
|
+
if (invoice.status === 'void') throw new FlowError('invalid', 'the invoice is void');
|
|
760
|
+
const rejected = await rejectSuggestions(tx, obligation, { actor });
|
|
761
|
+
const row = await update(tx, obligation, {
|
|
762
|
+
invoiceId: invoice.id,
|
|
763
|
+
draftBasis: basisOf(obligation),
|
|
764
|
+
...(obligation.accountingState === 'ambiguous' ? { accountingState: 'unknown' } : {}),
|
|
765
|
+
...(obligation.readyAt ? {} : { readyAt: sql`now()`, readyBy: actor }),
|
|
766
|
+
});
|
|
767
|
+
await addEvent(tx, row, 'invoice_linked', actor, { invoiceId: invoice.id, matchedBy: 'person', rejected });
|
|
768
|
+
return row;
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
671
772
|
// The obligation's view and the reasons for it, from its stored evidence only.
|
|
672
773
|
// `accounting` is false while no accounting system is connected: Flow's own
|
|
673
774
|
// records are then the only check, so a job nobody checked in one can draft.
|
|
@@ -174,13 +174,13 @@ export async function linkByCode(db, organisationId, catalogue, { items = null,
|
|
|
174
174
|
// A person's link: `externalId` in `system`'s list goes to this price item.
|
|
175
175
|
// For an accounting system it replaces the item's earlier link; a record
|
|
176
176
|
// linked elsewhere moves here.
|
|
177
|
-
|
|
177
|
+
async function writeLink(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId }) {
|
|
178
178
|
const item = await loadItem(db, organisationId, priceItemId);
|
|
179
179
|
const catalogue = catalogues.get(system);
|
|
180
180
|
if (!catalogue) throw new FlowError('conflict', `${system} is not connected, or its price list has not been read yet`);
|
|
181
181
|
const record = catalogue.byId.get(String(externalId ?? ''));
|
|
182
182
|
if (!record) throw notFound(`${catalogue.name} record`);
|
|
183
|
-
|
|
183
|
+
return db.transaction(async (tx) => {
|
|
184
184
|
await tx.delete(priceItemLinks).where(and(
|
|
185
185
|
eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.externalId, String(record.id)),
|
|
186
186
|
));
|
|
@@ -194,10 +194,35 @@ export async function linkPriceItem(db, organisationId, priceItemId, { system, e
|
|
|
194
194
|
}).returning();
|
|
195
195
|
return row;
|
|
196
196
|
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function linkPriceItem(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId = null }) {
|
|
200
|
+
const link = await writeLink(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId });
|
|
197
201
|
await remapJobs(db, organisationId);
|
|
198
202
|
return link;
|
|
199
203
|
}
|
|
200
204
|
|
|
205
|
+
// Many links at once (Reconcile prices, "Link sure matches"): each one as
|
|
206
|
+
// linkPriceItem makes it, then one remap. A link that fails stops the rest;
|
|
207
|
+
// the ones before it stay.
|
|
208
|
+
export const MAX_LINKS_AT_ONCE = 500;
|
|
209
|
+
export async function linkPriceItems(db, organisationId, links, { catalogues, personId = null }) {
|
|
210
|
+
if (!Array.isArray(links) || !links.length) throw invalid('links must be a list of { priceItemId, system, externalId }');
|
|
211
|
+
if (links.length > MAX_LINKS_AT_ONCE) throw invalid(`${MAX_LINKS_AT_ONCE} links at once at most`);
|
|
212
|
+
const made = [];
|
|
213
|
+
try {
|
|
214
|
+
for (const link of links) {
|
|
215
|
+
if (!link || typeof link !== 'object' || !link.priceItemId || !link.system || !link.externalId) {
|
|
216
|
+
throw invalid('each link needs priceItemId, system and externalId');
|
|
217
|
+
}
|
|
218
|
+
made.push(await writeLink(db, organisationId, link.priceItemId, link, { catalogues, personId }));
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
if (made.length) await remapJobs(db, organisationId);
|
|
222
|
+
}
|
|
223
|
+
return made;
|
|
224
|
+
}
|
|
225
|
+
|
|
201
226
|
export async function unlinkPriceItem(db, organisationId, linkId) {
|
|
202
227
|
const rows = await db.delete(priceItemLinks)
|
|
203
228
|
.where(and(eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.id, linkId))).returning();
|
|
@@ -30,11 +30,12 @@ const NO_LONGER_BILLABLE = 'the work system no longer reports the job as billabl
|
|
|
30
30
|
// Answers Map(obligationId -> { outcome, obligation, reason? }), where
|
|
31
31
|
// outcome is fresh (no read needed), read, review (sent to review) or
|
|
32
32
|
// waiting (the read could not run).
|
|
33
|
-
|
|
33
|
+
// `force` reads even a fresh report (a person's "Recheck").
|
|
34
|
+
export async function readBeforeAct(db, organisationId, obligations, { work, now = new Date(), actor, force = false }) {
|
|
34
35
|
const outcomes = new Map();
|
|
35
36
|
let toJob = null;
|
|
36
37
|
for (const obligation of obligations) {
|
|
37
|
-
if (!needsRead(obligation, { work, now })) {
|
|
38
|
+
if (!needsRead(obligation, { work, now: force ? new Date(8.64e15) : now })) {
|
|
38
39
|
outcomes.set(obligation.id, { outcome: 'fresh', obligation });
|
|
39
40
|
continue;
|
|
40
41
|
}
|