feat(workflows): implement remaining stub actions (prepare_quote, prepare_gallery, reserve_date)
These were the last guard-stubbed actions — offered in the builder palette but
refused on enable. Now all three are real, backed by existing converters:
- prepare_gallery: alias of prepare_event (a gallery IS an event in picpeak).
- reserve_date: convertToEvent({ skipInvoices: true }) — a pure draft date hold
with no money documents (new skipInvoices option on convertToEvent).
- prepare_quote: createQuote (customer entity) or duplicateQuote (quote entity),
producing a status='draft' quote; idempotent via ctx.vars.preparedQuoteId.
With no stubs left, the enable-guard switches from a hardcoded DOCUMENT_ACTIONS
list to a registry lookup: an action node whose config.action has no registered
handler is unimplementable. This can't drift from what the engine can run and
also catches typo'd/future actions. (Fixes the enable-route node mapping to
carry node.type so the action-node filter matches.)
Extends the single-connection SQLite in-trx deadlock fixes to the quote-create
path (prepare_quote runs unattended): nextQuoteNumber reads getAppSetting
through trx, createQuote logs via trx and hoists its hasColumnCached schema-drift
checks before the transaction.
Adds tests for reserve_date (no invoices), prepare_quote (draft, no deadlock),
and registry coverage; retargets the enable-guard refusal test at a genuinely
unregistered action. Full backend suite: 985 passed, 1 skipped.
This commit is contained in:
@@ -299,7 +299,10 @@ function formatNumberInTemplate(format, year, seq) {
|
||||
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
||||
// admin creates and could emit `Q-2026-AB12C3` after 5 retries.
|
||||
async function nextQuoteNumber(trx) {
|
||||
const format = (await getAppSetting('crm_quotes_number_format')) || 'Q-{YEAR}-{SEQ:04d}';
|
||||
// Read through `trx` when present — getAppSetting on the global db inside an
|
||||
// open transaction deadlocks the single-connection SQLite pool (prepare_quote
|
||||
// runs createQuote unattended from a workflow).
|
||||
const format = (await getAppSetting('crm_quotes_number_format', null, trx || db)) || 'Q-{YEAR}-{SEQ:04d}';
|
||||
const year = new Date().getFullYear();
|
||||
const seq = await claimNextSequence('quote', year, trx);
|
||||
return formatNumberInTemplate(format, year, seq);
|
||||
@@ -521,6 +524,15 @@ async function createQuote(payload, adminId) {
|
||||
// Resolve bank account for the chosen currency.
|
||||
const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId);
|
||||
|
||||
// Resolve schema-drift column checks BEFORE the transaction — a cold
|
||||
// hasColumnCached lookup hits the global db, which deadlocks the single-
|
||||
// connection SQLite pool if issued inside the trx (prepare_quote runs this
|
||||
// unattended from a workflow).
|
||||
const hasProjectId = await hasColumnCached('quotes', 'project_id');
|
||||
const hasVatCode = await hasColumnCached('quotes', 'vat_code');
|
||||
const hasEventType = await hasColumnCached('quotes', 'event_type');
|
||||
const hasBookingWorkflowId = await hasColumnCached('quotes', 'booking_workflow_id');
|
||||
|
||||
return await db.transaction(async (trx) => {
|
||||
// SQLite's 1-connection default deadlocks when claimNextSequence
|
||||
// opens its own micro-transaction inside this outer one — thread
|
||||
@@ -574,21 +586,21 @@ async function createQuote(payload, adminId) {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
// Migration 121 — optional link to a Project Overview project.
|
||||
if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) {
|
||||
if (payload.projectId !== undefined && hasProjectId) {
|
||||
row.project_id = payload.projectId || null;
|
||||
}
|
||||
// Migration 130 — snapshot the chosen output VAT code (immutable; the export
|
||||
// emits exactly this rather than re-deriving from the mutable rate→code map).
|
||||
if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) {
|
||||
if (payload.vatCode !== undefined && hasVatCode) {
|
||||
row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
|
||||
}
|
||||
// Migration 146 — event type (event_types.slug_prefix). Drives the type of
|
||||
// the event the quote converts into, instead of the old hardcoded 'wedding'.
|
||||
if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) {
|
||||
if (payload.eventType !== undefined && hasEventType) {
|
||||
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')) {
|
||||
if (payload.bookingWorkflowId !== undefined && hasBookingWorkflowId) {
|
||||
row.booking_workflow_id = payload.bookingWorkflowId || null;
|
||||
}
|
||||
const inserted = await trx('quotes').insert(row).returning('id');
|
||||
@@ -620,7 +632,9 @@ async function createQuote(payload, adminId) {
|
||||
}
|
||||
|
||||
try {
|
||||
await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`);
|
||||
// Pass `trx` so the audit insert rides the transaction's connection —
|
||||
// the global db here deadlocks the single-connection SQLite pool.
|
||||
await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`, trx);
|
||||
} catch (_) {}
|
||||
|
||||
logger.info('Quote created', { adminId, quoteId, quoteNumber });
|
||||
@@ -1610,41 +1624,46 @@ async function convertToEvent(quoteId, adminId, options = {}) {
|
||||
? paymentTermSnapshot.installments
|
||||
: [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }];
|
||||
|
||||
const spawnResult = await invoiceService.scheduleInvoicesForEvent({
|
||||
trx,
|
||||
eventId,
|
||||
quoteId: quote.id,
|
||||
customer,
|
||||
currency: quote.currency,
|
||||
language: quote.language,
|
||||
lineItems,
|
||||
totals: {
|
||||
net: quote.net_amount_minor,
|
||||
vatRate: quote.vat_rate,
|
||||
vat: quote.vat_amount_minor,
|
||||
shipping: quote.shipping_amount_minor,
|
||||
total: quote.total_amount_minor,
|
||||
},
|
||||
installments,
|
||||
eventDate: quote.event_date,
|
||||
// Inline event snapshot — same rationale as convertToInvoiceOnly
|
||||
// above (migration 123).
|
||||
eventName: quote.event_name,
|
||||
eventTimeStart: quote.event_time_start,
|
||||
eventTimeEnd: quote.event_time_end,
|
||||
adminId,
|
||||
ccPdfEmail: quote.cc_pdf_email,
|
||||
// Net 14 / 30 / 60 / 90 carry through from the quote's
|
||||
// payment-term template (same as convertToInvoiceOnly).
|
||||
netDays: paymentTermSnapshot?.net_days,
|
||||
// Migration 140 — propagate the quote's deal_uuid down through
|
||||
// every spawned invoice (same as convertToInvoiceOnly above).
|
||||
dealUuid: quote.deal_uuid,
|
||||
// Workflow draft-seam: the booking flow's prepare_event creates the
|
||||
// event's invoices on HOLD (no scheduled_send_at) so they wait for the
|
||||
// review gate + explicit send_document after the event date.
|
||||
hold: options.hold === true,
|
||||
});
|
||||
// `skipInvoices` (workflow reserve_date): create the event as a pure date
|
||||
// hold — no invoices scheduled at all. The other booking actions handle
|
||||
// money documents separately.
|
||||
const spawnResult = options.skipInvoices === true
|
||||
? { invoiceIds: [] }
|
||||
: await invoiceService.scheduleInvoicesForEvent({
|
||||
trx,
|
||||
eventId,
|
||||
quoteId: quote.id,
|
||||
customer,
|
||||
currency: quote.currency,
|
||||
language: quote.language,
|
||||
lineItems,
|
||||
totals: {
|
||||
net: quote.net_amount_minor,
|
||||
vatRate: quote.vat_rate,
|
||||
vat: quote.vat_amount_minor,
|
||||
shipping: quote.shipping_amount_minor,
|
||||
total: quote.total_amount_minor,
|
||||
},
|
||||
installments,
|
||||
eventDate: quote.event_date,
|
||||
// Inline event snapshot — same rationale as convertToInvoiceOnly
|
||||
// above (migration 123).
|
||||
eventName: quote.event_name,
|
||||
eventTimeStart: quote.event_time_start,
|
||||
eventTimeEnd: quote.event_time_end,
|
||||
adminId,
|
||||
ccPdfEmail: quote.cc_pdf_email,
|
||||
// Net 14 / 30 / 60 / 90 carry through from the quote's
|
||||
// payment-term template (same as convertToInvoiceOnly).
|
||||
netDays: paymentTermSnapshot?.net_days,
|
||||
// Migration 140 — propagate the quote's deal_uuid down through
|
||||
// every spawned invoice (same as convertToInvoiceOnly above).
|
||||
dealUuid: quote.deal_uuid,
|
||||
// Workflow draft-seam: the booking flow's prepare_event creates the
|
||||
// event's invoices on HOLD (no scheduled_send_at) so they wait for the
|
||||
// review gate + explicit send_document after the event date.
|
||||
hold: options.hold === true,
|
||||
});
|
||||
|
||||
await trx('quotes').where({ id: quote.id }).update({
|
||||
status: 'converted',
|
||||
|
||||
@@ -16,16 +16,6 @@
|
||||
*/
|
||||
const registry = require('./registry');
|
||||
|
||||
// Document actions still awaiting wiring (the enable-guard refuses flows that use
|
||||
// any of these). prepare_contract / prepare_event / prepare_invoice /
|
||||
// send_document are now implemented below (draft-seam booking cutover), so
|
||||
// they're off this list — which makes booking_full / booking_simple enableable.
|
||||
const DOCUMENT_ACTIONS = [
|
||||
'prepare_quote',
|
||||
'prepare_gallery',
|
||||
'reserve_date',
|
||||
];
|
||||
|
||||
// --- Conditions ---
|
||||
|
||||
// True once the run's invoice entity is settled (paid_at set, status paid, or
|
||||
@@ -241,26 +231,54 @@ registry.registerAction('prepare_contract', async (ctx) => {
|
||||
return { contract_prepared: res.contractId, alreadyConverted: !!res.alreadyConverted };
|
||||
});
|
||||
|
||||
// Prepare a DRAFT event/gallery from the accepted quote. convertToEvent creates
|
||||
// the event as is_draft=true AND schedules its invoices — on HOLD here so they
|
||||
// wait for the review gate + send_document. The created invoice ids are stashed
|
||||
// so the flow's downstream prepare_invoice ADOPTS them (instead of double-
|
||||
// creating, which would also throw ALREADY_CONVERTED_TO_EVENT). Idempotent: a
|
||||
// re-entry returns the existing event + invoices.
|
||||
registry.registerAction('prepare_event', async (ctx) => {
|
||||
// Create a DRAFT event/gallery from the accepted quote. convertToEvent creates
|
||||
// the event as is_draft=true AND (unless skipInvoices) schedules its invoices —
|
||||
// on HOLD here so they wait for the review gate + send_document. The created
|
||||
// invoice ids are stashed so the flow's downstream prepare_invoice ADOPTS them
|
||||
// (instead of double-creating, which would also throw ALREADY_CONVERTED_TO_EVENT).
|
||||
// Shared by prepare_event, prepare_gallery (alias — a gallery IS an event in
|
||||
// picpeak), and reserve_date (skipInvoices: a pure date hold, no money docs).
|
||||
async function doPrepareEvent(ctx, label, { skipInvoices = false } = {}) {
|
||||
const quoteId = ctx.run.entity_id;
|
||||
if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: 'prepare_event needs a quote entity' };
|
||||
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_event', quoteId };
|
||||
if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: `${label} needs a quote entity` };
|
||||
if (ctx.vars?.__dryRun) return { dryRun: true, would: label, quoteId };
|
||||
if (ctx.vars.preparedEventId) {
|
||||
return { already: true, eventId: ctx.vars.preparedEventId, invoiceIds: ctx.vars.preparedInvoiceIds || [] };
|
||||
}
|
||||
const adminId = await resolveActor(ctx);
|
||||
const res = await require('../quoteService').convertToEvent(quoteId, adminId, { hold: true });
|
||||
const res = await require('../quoteService').convertToEvent(quoteId, adminId, { hold: true, skipInvoices });
|
||||
ctx.vars.preparedEventId = res.eventId;
|
||||
// The flow's prepare_invoice short-circuits on a populated preparedInvoiceIds,
|
||||
// so the event's held invoices flow straight through to send_document.
|
||||
ctx.vars.preparedInvoiceIds = res.invoiceIds || [];
|
||||
return { event_prepared: res.eventId, invoiceIds: ctx.vars.preparedInvoiceIds, alreadyConverted: !!res.alreadyConverted };
|
||||
}
|
||||
|
||||
registry.registerAction('prepare_event', (ctx) => doPrepareEvent(ctx, 'prepare_event'));
|
||||
// A gallery IS an event in picpeak — same draft-seam behaviour.
|
||||
registry.registerAction('prepare_gallery', (ctx) => doPrepareEvent(ctx, 'prepare_gallery'));
|
||||
// Reserve the date only: create the draft event as a pure calendar hold with NO
|
||||
// invoices. A flow can invoice later (or never).
|
||||
registry.registerAction('reserve_date', (ctx) => doPrepareEvent(ctx, 'reserve_date', { skipInvoices: true }));
|
||||
|
||||
// Create a DRAFT quote. From a quote entity (e.g. quote.declined → re-quote) it
|
||||
// duplicates that quote; from a customer entity (customer.created) it opens a
|
||||
// blank draft quote for them. Idempotent via ctx.vars.preparedQuoteId.
|
||||
registry.registerAction('prepare_quote', async (ctx) => {
|
||||
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_quote', entity: ctx.run.entity_type };
|
||||
if (ctx.vars.preparedQuoteId) return { already: true, quoteId: ctx.vars.preparedQuoteId };
|
||||
const adminId = await resolveActor(ctx);
|
||||
const quoteService = require('../quoteService');
|
||||
let quoteId;
|
||||
if (ctx.run.entity_type === 'quote' && ctx.run.entity_id) {
|
||||
quoteId = await quoteService.duplicateQuote(ctx.run.entity_id, adminId);
|
||||
} else if (ctx.run.entity_type === 'customer' && ctx.run.entity_id) {
|
||||
quoteId = await quoteService.createQuote({ customerAccountId: ctx.run.entity_id }, adminId);
|
||||
} else {
|
||||
return { skipped: true, reason: 'prepare_quote needs a quote or customer entity' };
|
||||
}
|
||||
ctx.vars.preparedQuoteId = quoteId;
|
||||
return { quote_prepared: quoteId };
|
||||
});
|
||||
|
||||
// Prepare DRAFT invoice(s) from the accepted quote — created on HOLD (no
|
||||
@@ -315,13 +333,4 @@ registry.registerAction('send_document', async (ctx) => {
|
||||
return { skipped: true, reason: `send_document for '${doc}' not implemented yet` };
|
||||
});
|
||||
|
||||
// Create/prepare-document actions — registered so flows referencing them are
|
||||
// valid; service wiring is a follow-up. Records a skipped step (observable).
|
||||
for (const key of DOCUMENT_ACTIONS) {
|
||||
registry.registerAction(key, async (ctx) => {
|
||||
ctx.logger?.warn?.('[workflow] document action not yet wired', { action: key, runId: ctx.run.id });
|
||||
return { skipped: true, reason: `action ${key} not yet implemented` };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { DOCUMENT_ACTIONS };
|
||||
module.exports = {};
|
||||
|
||||
Reference in New Issue
Block a user