feat(workflows): per-quote booking-workflow picker + quote→invoice (no gallery) built-in

A quote can now choose which flow runs on acceptance instead of every enabled
quote.accepted flow firing. Migration 147 adds quotes.booking_workflow_id; the
editor shows a "Booking workflow (on acceptance)" dropdown listing the
quote.accepted flows (workflow-engine flag only); emitQuoteEvent passes it as
the new emitWorkflowEvent targetWorkflowId so ONLY the picked flow runs (still
gated on enabled + trigger match → a disabled/None selection runs nothing).

Adds the booking_invoice_only built-in (quote.accepted → prepare invoice →
review gate → send; no event/gallery, no wait), the variant requested for
shoots billed without an online gallery. Disabled stub like the other booking
flows until the prepare_*/send_document cutover.

Tests: targetWorkflowId runs only the selected flow; invoice-only built-in has
no wait/prepare_event.
This commit is contained in:
Luca
2026-06-23 23:15:31 +02:00
parent eec262b0a7
commit d14f1d850c
10 changed files with 170 additions and 2 deletions
@@ -305,6 +305,15 @@ describe('workflow engine', () => {
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version }); const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true); expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
// Invoice-only booking variant (quote → invoice, no gallery).
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
expect(invoiceOnly).toBeTruthy();
expect(!!invoiceOnly.enabled).toBe(false);
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first(); const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy(); expect(bookingFull).toBeTruthy();
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
@@ -390,6 +399,27 @@ describe('workflow engine', () => {
expect(res.sent).toBe(0); expect(res.sent).toBe(0);
}); });
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
// Two enabled flows on the same trigger — the quote picks one.
const chosen = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
const other = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'o1', to: 'o2' }],
});
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
expect(runIds.length).toBe(1);
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
expect(chosenRuns.length).toBe(1); // only the selected flow ran
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
});
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => { test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The // The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
// admin can approve at the gate whenever; the run then parks at the wait and // admin can approve at the gate whenever; the run then parks at the wait and
@@ -0,0 +1,25 @@
/**
* Migration 147: let a quote pick the booking workflow it runs on acceptance.
*
* Today quote.accepted fans out to every enabled flow with that trigger. This
* column lets the admin choose ONE workflow per quote (e.g. "with contract" vs
* "invoice only, no gallery"); emitQuoteEvent passes it as targetWorkflowId so
* only the chosen flow runs. Plain nullable integer (not a hard FK) — the emit
* re-checks the workflow exists + is enabled + matches the trigger at fire time,
* so a deleted/disabled selection just runs nothing.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'booking_workflow_id'))) {
await knex.schema.alterTable('quotes', (t) => {
t.integer('booking_workflow_id');
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'booking_workflow_id')) {
await knex.schema.alterTable('quotes', (t) => t.dropColumn('booking_workflow_id'));
}
};
+2
View File
@@ -87,6 +87,7 @@ function transformQuote(q) {
eventName: q.event_name, eventName: q.event_name,
eventDate: q.event_date, eventDate: q.event_date,
eventType: q.event_type ?? null, eventType: q.event_type ?? null,
bookingWorkflowId: q.booking_workflow_id ?? null,
eventTimeStart: q.event_time_start, eventTimeStart: q.event_time_start,
eventTimeEnd: q.event_time_end, eventTimeEnd: q.event_time_end,
expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours), expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours),
@@ -214,6 +215,7 @@ function mapPayloadToService(body) {
language: 'language', currency: 'currency', language: 'language', currency: 'currency',
issueDate: 'issueDate', validUntil: 'validUntil', issueDate: 'issueDate', validUntil: 'validUntil',
eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType', eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType',
bookingWorkflowId: 'bookingWorkflowId',
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd', eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
expectedDurationHours: 'expectedDurationHours', expectedDurationHours: 'expectedDurationHours',
paymentTermTemplateId: 'paymentTermTemplateId', paymentTermTemplateId: 'paymentTermTemplateId',
+37
View File
@@ -130,6 +130,29 @@ function buildBookingSimpleGraph() {
return { nodes, edges }; return { nodes, edges };
} }
// Booking — quote accepted → prepare invoice → admin review gate → send. No
// event/gallery and no wait: the invoice goes out as soon as the admin approves
// it. For shoots billed without a delivered online gallery. Same stub caveat as
// the other booking flows (prepare_invoice/send_document not yet wired).
function buildBookingInvoiceOnlyGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 110 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 220 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 330 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 440 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'prepInvoice' },
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
}
// Pre-event reminder — fired by the scheduler at event_date daysBefore (see // Pre-event reminder — fired by the scheduler at event_date daysBefore (see
// emitDueEventReminders). The notify_pre_event action DELEGATES to // emitDueEventReminders). The notify_pre_event action DELEGATES to
// eventReminderService.sendReminderForEvent, so the email is byte-identical to // eventReminderService.sendReminderForEvent, so the email is byte-identical to
@@ -290,6 +313,20 @@ const BUILTINS = [
+ 'full booking flow; disabled by default.', + 'full booking flow; disabled by default.',
build: async () => buildBookingSimpleGraph(), build: async () => buildBookingSimpleGraph(),
}, },
{
key: 'booking_invoice_only',
version: 1,
enabled: false,
name: 'Booking — quote → invoice, no gallery (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
'For shoots billed without an online gallery: on quote acceptance prepare the invoice, the '
+ 'admin reviews + approves it, and it is sent right away (no event/gallery, no wait). Pick '
+ 'this flow per quote via the booking-workflow selector. Same review-before-send rule and '
+ 'stub caveat as the other booking flows; disabled by default.',
build: async () => buildBookingInvoiceOnlyGraph(),
},
]; ];
let booted = false; let booted = false;
+17
View File
@@ -587,6 +587,10 @@ async function createQuote(payload, adminId) {
if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) { if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) {
row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null; row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
} }
// Migration 147 — the booking workflow this quote runs on acceptance.
if (payload.bookingWorkflowId !== undefined && await hasColumnCached('quotes', 'booking_workflow_id')) {
row.booking_workflow_id = payload.bookingWorkflowId || null;
}
const inserted = await trx('quotes').insert(row).returning('id'); const inserted = await trx('quotes').insert(row).returning('id');
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -719,6 +723,10 @@ async function updateQuote(id, payload, adminId) {
if (Object.prototype.hasOwnProperty.call(payload, 'eventType') && await hasColumnCached('quotes', 'event_type')) { if (Object.prototype.hasOwnProperty.call(payload, 'eventType') && await hasColumnCached('quotes', 'event_type')) {
updates.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null; updates.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
} }
// Migration 147 — selected booking workflow.
if (Object.prototype.hasOwnProperty.call(payload, 'bookingWorkflowId') && await hasColumnCached('quotes', 'booking_workflow_id')) {
updates.booking_workflow_id = payload.bookingWorkflowId || null;
}
await trx('quotes').where({ id }).update(updates); await trx('quotes').where({ id }).update(updates);
// When linked to a project, cascade across the deal lineage so the linked // When linked to a project, cascade across the deal lineage so the linked
@@ -1067,9 +1075,16 @@ async function emitQuoteEvent(quote, status) {
const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
customerEmail = c?.email || null; customerEmail = c?.email || null;
} }
// On acceptance, if the admin picked a booking workflow on the quote, run
// ONLY that flow (instead of fanning out to every enabled quote.accepted
// flow). Other statuses keep the normal fan-out.
const targetWorkflowId = (status === 'accepted' && quote.booking_workflow_id)
? quote.booking_workflow_id
: null;
await require('./workflows').emitWorkflowEvent(`quote.${status}`, { await require('./workflows').emitWorkflowEvent(`quote.${status}`, {
entityType: 'quote', entityType: 'quote',
entityId: quote.id, entityId: quote.id,
targetWorkflowId,
payload: { payload: {
quoteId: quote.id, quoteId: quote.id,
quoteNumber: quote.quote_number, quoteNumber: quote.quote_number,
@@ -1077,7 +1092,9 @@ async function emitQuoteEvent(quote, status) {
customerEmail, customerEmail,
eventName: quote.event_name || null, eventName: quote.event_name || null,
eventDate: quote.event_date || null, eventDate: quote.event_date || null,
eventType: quote.event_type || null,
totalMinor: quote.total_amount_minor ?? null, totalMinor: quote.total_amount_minor ?? null,
bookingWorkflowId: quote.booking_workflow_id || null,
}, },
}); });
} catch (_) { /* best-effort */ } } catch (_) { /* best-effort */ }
+8 -2
View File
@@ -240,7 +240,7 @@ async function resumeRun(runId, { decisionHandle = null } = {}) {
* workflow (idempotent via dedup_key) and starts it. Never throws — safe to * workflow (idempotent via dedup_key) and starts it. Never throws — safe to
* call after a caller's commit. Fails CLOSED if the flag system is unavailable. * call after a caller's commit. Fails CLOSED if the flag system is unavailable.
*/ */
async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {} } = {}) { async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null } = {}) {
try { try {
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
let enabled = false; let enabled = false;
@@ -250,7 +250,13 @@ async function emitWorkflowEvent(triggerType, { entityType = null, entityId = nu
} }
if (!enabled) return []; if (!enabled) return [];
const workflows = await db('workflows').where({ enabled: true, trigger_type: triggerType }); // targetWorkflowId restricts the fan-out to a SINGLE chosen flow — used when
// the entity explicitly selected which flow to run (e.g. a quote picks its
// booking workflow). Still gated on enabled + matching trigger_type, so a
// disabled/mismatched selection simply runs nothing.
const q = db('workflows').where({ enabled: true, trigger_type: triggerType });
if (targetWorkflowId != null) q.where({ id: targetWorkflowId });
const workflows = await q;
const runIds = []; const runIds = [];
for (const wf of workflows) { for (const wf of workflows) {
const tcfg = parseJson(wf.trigger_config, {}); const tcfg = parseJson(wf.trigger_config, {});
+4
View File
@@ -5236,6 +5236,10 @@
"eventType": "Anlasstyp", "eventType": "Anlasstyp",
"eventTypeNone": "— Standard verwenden —", "eventTypeNone": "— Standard verwenden —",
"eventTypeHint": "Wird für den Anlass verwendet, der bei Annahme dieses Angebots erstellt wird.", "eventTypeHint": "Wird für den Anlass verwendet, der bei Annahme dieses Angebots erstellt wird.",
"bookingWorkflow": "Buchungs-Workflow (bei Annahme)",
"bookingWorkflowNone": "— Keiner —",
"bookingWorkflowDisabled": "(deaktiviert)",
"bookingWorkflowHint": "Der Ablauf, der startet, wenn die Kundin/der Kunde annimmt. „Keiner“ = kein Buchungsablauf. Der Ablauf muss aktiviert sein, um zu starten.",
"eventSection": "Anlass (optional)", "eventSection": "Anlass (optional)",
"eventTimeEnd": "Ende", "eventTimeEnd": "Ende",
"eventTimeStart": "Beginn", "eventTimeStart": "Beginn",
+4
View File
@@ -5234,6 +5234,10 @@
"eventType": "Event type", "eventType": "Event type",
"eventTypeNone": "— Use default —", "eventTypeNone": "— Use default —",
"eventTypeHint": "Used for the event created when this quote is accepted.", "eventTypeHint": "Used for the event created when this quote is accepted.",
"bookingWorkflow": "Booking workflow (on acceptance)",
"bookingWorkflowNone": "— None —",
"bookingWorkflowDisabled": "(disabled)",
"bookingWorkflowHint": "The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire.",
"eventSection": "Event (optional)", "eventSection": "Event (optional)",
"eventTimeEnd": "End", "eventTimeEnd": "End",
"eventTimeStart": "Start", "eventTimeStart": "Start",
@@ -29,6 +29,8 @@ import { VatRateSelect } from '../../../components/admin/VatRateSelect';
import { accountingService } from '../../../services/accounting.service'; import { accountingService } from '../../../services/accounting.service';
import { vatCodesService } from '../../../services/vatCodes.service'; import { vatCodesService } from '../../../services/vatCodes.service';
import { eventTypesService } from '../../../services/eventTypes.service'; import { eventTypesService } from '../../../services/eventTypes.service';
import { workflowsService } from '../../../services/workflows.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel'; import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service'; import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service'; import { userManagementService } from '../../../services/userManagement.service';
@@ -49,6 +51,7 @@ interface FormState {
eventName: string; eventName: string;
eventDate: string; eventDate: string;
eventType: string; eventType: string;
bookingWorkflowId: number | null;
eventTimeStart: string; eventTimeStart: string;
eventTimeEnd: string; eventTimeEnd: string;
expectedDurationHours: string; expectedDurationHours: string;
@@ -86,6 +89,7 @@ const empty: FormState = {
eventName: '', eventName: '',
eventDate: '', eventDate: '',
eventType: '', eventType: '',
bookingWorkflowId: null,
eventTimeStart: '', eventTimeStart: '',
eventTimeEnd: '', eventTimeEnd: '',
expectedDurationHours: '', expectedDurationHours: '',
@@ -119,6 +123,7 @@ function buildPayload(f: FormState): QuoteCreatePayload {
eventName: f.eventName || undefined, eventName: f.eventName || undefined,
eventDate: f.eventDate || undefined, eventDate: f.eventDate || undefined,
eventType: f.eventType || null, eventType: f.eventType || null,
bookingWorkflowId: f.bookingWorkflowId,
eventTimeStart: f.eventTimeStart || undefined, eventTimeStart: f.eventTimeStart || undefined,
eventTimeEnd: f.eventTimeEnd || undefined, eventTimeEnd: f.eventTimeEnd || undefined,
expectedDurationHours: f.expectedDurationHours ? Number(f.expectedDurationHours) : undefined, expectedDurationHours: f.expectedDurationHours ? Number(f.expectedDurationHours) : undefined,
@@ -204,6 +209,19 @@ export const QuoteEditorPage: React.FC = () => {
// Active event types — drives the event-type dropdown (and the type of the // Active event types — drives the event-type dropdown (and the type of the
// event this quote converts into). // event this quote converts into).
const { data: eventTypes = [] } = useQuery({ queryKey: ['event-types-active'], queryFn: () => eventTypesService.getActiveEventTypes() }); const { data: eventTypes = [] } = useQuery({ queryKey: ['event-types-active'], queryFn: () => eventTypesService.getActiveEventTypes() });
// Booking-workflow picker: the flows that run on quote acceptance. Only shown
// when the workflow engine is live.
const { flags } = useFeatureFlags();
const workflowsLive = !!flags.workflows;
const { data: allWorkflows = [] } = useQuery({
queryKey: ['workflows'],
queryFn: () => workflowsService.list(),
enabled: workflowsLive,
});
const bookingWorkflows = useMemo(
() => allWorkflows.filter((w) => w.trigger_type === 'quote.accepted'),
[allWorkflows],
);
const didSeedVatRef = useRef(false); const didSeedVatRef = useRef(false);
useEffect(() => { useEffect(() => {
if (isEdit || didSeedVatRef.current) return; if (isEdit || didSeedVatRef.current) return;
@@ -239,6 +257,7 @@ export const QuoteEditorPage: React.FC = () => {
eventName: q.eventName || '', eventName: q.eventName || '',
eventDate: q.eventDate || '', eventDate: q.eventDate || '',
eventType: q.eventType || '', eventType: q.eventType || '',
bookingWorkflowId: q.bookingWorkflowId ?? null,
eventTimeStart: q.eventTimeStart || '', eventTimeStart: q.eventTimeStart || '',
eventTimeEnd: q.eventTimeEnd || '', eventTimeEnd: q.eventTimeEnd || '',
expectedDurationHours: q.expectedDurationHours?.toString() || '', expectedDurationHours: q.expectedDurationHours?.toString() || '',
@@ -557,6 +576,28 @@ export const QuoteEditorPage: React.FC = () => {
{t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')} {t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')}
</p> </p>
</div> </div>
{workflowsLive && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('quotes.field.bookingWorkflow', 'Booking workflow (on acceptance)')}
</label>
<select
value={form.bookingWorkflowId ?? ''}
onChange={(e) => setForm((f) => ({ ...f, bookingWorkflowId: e.target.value ? Number(e.target.value) : null }))}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('quotes.field.bookingWorkflowNone', '— None —')}</option>
{bookingWorkflows.map((w) => (
<option key={w.id} value={w.id}>
{w.name}{(w.enabled === true || w.enabled === 1) ? '' : ` ${t('quotes.field.bookingWorkflowDisabled', '(disabled)')}`}
</option>
))}
</select>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('quotes.field.bookingWorkflowHint', 'The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire.')}
</p>
</div>
)}
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate} <LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} /> onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart} <TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
+2
View File
@@ -64,6 +64,7 @@ export interface QuoteSummary {
eventName: string | null; eventName: string | null;
eventDate: string | null; eventDate: string | null;
eventType: string | null; eventType: string | null;
bookingWorkflowId: number | null;
totalAmountMinor: number; totalAmountMinor: number;
sentAt: string | null; sentAt: string | null;
acceptedAt: string | null; acceptedAt: string | null;
@@ -180,6 +181,7 @@ export interface QuoteCreatePayload {
eventName?: string; eventName?: string;
eventDate?: string; eventDate?: string;
eventType?: string | null; eventType?: string | null;
bookingWorkflowId?: number | null;
eventTimeStart?: string; eventTimeStart?: string;
eventTimeEnd?: string; eventTimeEnd?: string;
expectedDurationHours?: number; expectedDurationHours?: number;