From d14f1d850cc995b2cb1119ba0424f123feba50ec Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:15:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(workflows):=20per-quote=20booking-workflow?= =?UTF-8?q?=20picker=20+=20quote=E2=86=92invoice=20(no=20gallery)=20built-?= =?UTF-8?q?in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integration/workflowEngine.test.js | 30 ++++++++++++++ .../core/147_add_quote_booking_workflow.js | 25 +++++++++++ backend/src/routes/adminQuotes.js | 2 + backend/src/services/_workflowSeedBoot.js | 37 +++++++++++++++++ backend/src/services/quoteService.js | 17 ++++++++ backend/src/services/workflows/engine.js | 10 ++++- frontend/src/i18n/locales/de.json | 4 ++ frontend/src/i18n/locales/en.json | 4 ++ .../pages/admin/quotes/QuoteEditorPage.tsx | 41 +++++++++++++++++++ frontend/src/services/quotes.service.ts | 2 + 10 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/core/147_add_quote_booking_workflow.js diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index f7c43993..d419c12b 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -305,6 +305,15 @@ describe('workflow engine', () => { 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); + // 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(); expect(bookingFull).toBeTruthy(); expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled @@ -390,6 +399,27 @@ describe('workflow engine', () => { 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 () => { // 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 diff --git a/backend/migrations/core/147_add_quote_booking_workflow.js b/backend/migrations/core/147_add_quote_booking_workflow.js new file mode 100644 index 00000000..f61ab48a --- /dev/null +++ b/backend/migrations/core/147_add_quote_booking_workflow.js @@ -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')); + } +}; diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js index 70d454e7..afaeff29 100644 --- a/backend/src/routes/adminQuotes.js +++ b/backend/src/routes/adminQuotes.js @@ -87,6 +87,7 @@ function transformQuote(q) { eventName: q.event_name, eventDate: q.event_date, eventType: q.event_type ?? null, + bookingWorkflowId: q.booking_workflow_id ?? null, eventTimeStart: q.event_time_start, eventTimeEnd: q.event_time_end, expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours), @@ -214,6 +215,7 @@ function mapPayloadToService(body) { language: 'language', currency: 'currency', issueDate: 'issueDate', validUntil: 'validUntil', eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType', + bookingWorkflowId: 'bookingWorkflowId', eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd', expectedDurationHours: 'expectedDurationHours', paymentTermTemplateId: 'paymentTermTemplateId', diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index 37bad478..b847e72e 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -130,6 +130,29 @@ function buildBookingSimpleGraph() { 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 // emitDueEventReminders). The notify_pre_event action DELEGATES to // eventReminderService.sendReminderForEvent, so the email is byte-identical to @@ -290,6 +313,20 @@ const BUILTINS = [ + 'full booking flow; disabled by default.', 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; diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index abd6beb3..98aefae9 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -587,6 +587,10 @@ async function createQuote(payload, adminId) { if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) { 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 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')) { 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); // 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(); 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}`, { entityType: 'quote', entityId: quote.id, + targetWorkflowId, payload: { quoteId: quote.id, quoteNumber: quote.quote_number, @@ -1077,7 +1092,9 @@ async function emitQuoteEvent(quote, status) { customerEmail, eventName: quote.event_name || null, eventDate: quote.event_date || null, + eventType: quote.event_type || null, totalMinor: quote.total_amount_minor ?? null, + bookingWorkflowId: quote.booking_workflow_id || null, }, }); } catch (_) { /* best-effort */ } diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js index 2e2f76c5..0453c248 100644 --- a/backend/src/services/workflows/engine.js +++ b/backend/src/services/workflows/engine.js @@ -240,7 +240,7 @@ async function resumeRun(runId, { decisionHandle = null } = {}) { * 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. */ -async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {} } = {}) { +async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null } = {}) { try { const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); let enabled = false; @@ -250,7 +250,13 @@ async function emitWorkflowEvent(triggerType, { entityType = null, entityId = nu } 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 = []; for (const wf of workflows) { const tcfg = parseJson(wf.trigger_config, {}); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index cb94ffcc..ac5bb6d2 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -5236,6 +5236,10 @@ "eventType": "Anlasstyp", "eventTypeNone": "— Standard verwenden —", "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)", "eventTimeEnd": "Ende", "eventTimeStart": "Beginn", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index afd8533b..57ba8414 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -5234,6 +5234,10 @@ "eventType": "Event type", "eventTypeNone": "— Use default —", "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)", "eventTimeEnd": "End", "eventTimeStart": "Start", diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx index eb290eca..cea8f4b6 100644 --- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx @@ -29,6 +29,8 @@ import { VatRateSelect } from '../../../components/admin/VatRateSelect'; import { accountingService } from '../../../services/accounting.service'; import { vatCodesService } from '../../../services/vatCodes.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 { customerAdminService } from '../../../services/customerAdmin.service'; import { userManagementService } from '../../../services/userManagement.service'; @@ -49,6 +51,7 @@ interface FormState { eventName: string; eventDate: string; eventType: string; + bookingWorkflowId: number | null; eventTimeStart: string; eventTimeEnd: string; expectedDurationHours: string; @@ -86,6 +89,7 @@ const empty: FormState = { eventName: '', eventDate: '', eventType: '', + bookingWorkflowId: null, eventTimeStart: '', eventTimeEnd: '', expectedDurationHours: '', @@ -119,6 +123,7 @@ function buildPayload(f: FormState): QuoteCreatePayload { eventName: f.eventName || undefined, eventDate: f.eventDate || undefined, eventType: f.eventType || null, + bookingWorkflowId: f.bookingWorkflowId, eventTimeStart: f.eventTimeStart || undefined, eventTimeEnd: f.eventTimeEnd || 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 // event this quote converts into). 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); useEffect(() => { if (isEdit || didSeedVatRef.current) return; @@ -239,6 +257,7 @@ export const QuoteEditorPage: React.FC = () => { eventName: q.eventName || '', eventDate: q.eventDate || '', eventType: q.eventType || '', + bookingWorkflowId: q.bookingWorkflowId ?? null, eventTimeStart: q.eventTimeStart || '', eventTimeEnd: q.eventTimeEnd || '', 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.')}

+ {workflowsLive && ( +
+ + +

+ {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.')} +

+
+ )} setForm((f) => ({ ...f, eventDate: iso }))} />