feat(projects): linking a quote/contract cascades the whole deal into the project
linkDealToProject(dealUuid, projectId): links every quote + contract sharing the deal_uuid, re-points the events the deal converted into (so their invoices/emails/gallery roll up), and adopts the deal's customer onto an empty project. Invoked from the assign endpoints AND the quote/contract editors' project picker (create + update). Drop a quote on an empty project and its linked contract, event and invoices populate the cockpit automatically. Verified on a booted DB: assignQuote on an empty project propagates project_id to the contract + event, adopts the customer, and the overview rolls up all four document types.
This commit is contained in:
@@ -808,6 +808,9 @@ async function createContract(payload, adminId) {
|
||||
row.project_id = payload.projectId || null;
|
||||
}
|
||||
const inserted = await trx('contracts').insert(row).returning('id');
|
||||
if (row.project_id && row.deal_uuid) {
|
||||
await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
|
||||
}
|
||||
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
// Seed with every active system block, toggled on. Per-section
|
||||
@@ -900,6 +903,12 @@ async function updateContract(id, payload, adminId) {
|
||||
}
|
||||
await trx('contracts').where({ id }).update(updates);
|
||||
|
||||
// Cascade across the deal lineage (linked quote / event / invoices).
|
||||
if (updates.project_id) {
|
||||
const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first();
|
||||
await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
|
||||
}
|
||||
|
||||
// Replace inclusions only when the caller sent an explicit list.
|
||||
// (Editor's "save" sends every row; an inline "toggle" save could
|
||||
// send a partial update — current frontend always sends full list.)
|
||||
|
||||
@@ -140,7 +140,61 @@ async function assignEvent(projectId, eventId) {
|
||||
return { projectId, eventId };
|
||||
}
|
||||
|
||||
/** Attach (or, with projectId=null, detach) a quote/contract to a project. */
|
||||
/**
|
||||
* Cascade a project link across a whole deal's lineage. Given a deal_uuid, link
|
||||
* every quote + contract in that deal to the project, re-point every event the
|
||||
* deal produced (so its invoices / emails / gallery roll up automatically), and
|
||||
* adopt the deal's customer onto the project when it has none. This is what
|
||||
* makes "drop a quote on an empty project" fill the cockpit with the linked
|
||||
* contract, event and invoices. Idempotent; pass a trx to run inside a txn.
|
||||
*/
|
||||
async function linkDealToProject(dealUuid, projectId, conn = db) {
|
||||
if (!dealUuid || !projectId) return;
|
||||
|
||||
if (await hasColumnCached('quotes', 'project_id') && await hasColumnCached('quotes', 'deal_uuid')) {
|
||||
await conn('quotes').where({ deal_uuid: dealUuid }).update({ project_id: projectId });
|
||||
}
|
||||
if (await hasColumnCached('contracts', 'project_id') && await hasColumnCached('contracts', 'deal_uuid')) {
|
||||
await conn('contracts').where({ deal_uuid: dealUuid }).update({ project_id: projectId });
|
||||
}
|
||||
|
||||
// Collect every event the deal converted into: quote/contract converted_event_id
|
||||
// + any invoice's event_id. Re-point them so invoices/emails/gallery roll up.
|
||||
const eventIds = new Set();
|
||||
let dealCustomerId = null;
|
||||
if (await hasColumnCached('quotes', 'deal_uuid')) {
|
||||
for (const q of await conn('quotes').where({ deal_uuid: dealUuid }).select('converted_event_id', 'customer_account_id')) {
|
||||
if (q.converted_event_id) eventIds.add(q.converted_event_id);
|
||||
if (q.customer_account_id && !dealCustomerId) dealCustomerId = q.customer_account_id;
|
||||
}
|
||||
}
|
||||
if (await hasColumnCached('contracts', 'deal_uuid') && await hasColumnCached('contracts', 'converted_event_id')) {
|
||||
for (const c of await conn('contracts').where({ deal_uuid: dealUuid }).select('converted_event_id', 'customer_account_id')) {
|
||||
if (c.converted_event_id) eventIds.add(c.converted_event_id);
|
||||
if (c.customer_account_id && !dealCustomerId) dealCustomerId = c.customer_account_id;
|
||||
}
|
||||
}
|
||||
if (await hasColumnCached('invoices', 'deal_uuid')) {
|
||||
for (const inv of await conn('invoices').where({ deal_uuid: dealUuid }).select('event_id', 'customer_account_id')) {
|
||||
if (inv.event_id) eventIds.add(inv.event_id);
|
||||
if (inv.customer_account_id && !dealCustomerId) dealCustomerId = inv.customer_account_id;
|
||||
}
|
||||
}
|
||||
if (eventIds.size && await hasColumnCached('events', 'project_id')) {
|
||||
await conn('events').whereIn('id', Array.from(eventIds)).update({ project_id: projectId });
|
||||
}
|
||||
|
||||
// Adopt the deal's customer onto a still-unassigned project.
|
||||
if (dealCustomerId) {
|
||||
const project = await conn('projects').where({ id: projectId }).select('customer_account_id').first();
|
||||
if (project && project.customer_account_id == null) {
|
||||
await conn('projects').where({ id: projectId }).update({ customer_account_id: dealCustomerId, updated_at: new Date() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach (or, with projectId=null, detach) a quote/contract to a project.
|
||||
* Attaching cascades the link across the deal lineage (see linkDealToProject). */
|
||||
async function assignDocument(table, projectId, documentId) {
|
||||
if (!(await hasColumnCached(table, 'project_id'))) {
|
||||
throw new AppError('This instance has no project_id column yet — run migrations', 409);
|
||||
@@ -152,6 +206,9 @@ async function assignDocument(table, projectId, documentId) {
|
||||
const doc = await db(table).where({ id: documentId }).first();
|
||||
if (!doc) throw new AppError('Document not found', 404);
|
||||
await db(table).where({ id: documentId }).update({ project_id: projectId || null });
|
||||
if (projectId && doc.deal_uuid) {
|
||||
await linkDealToProject(doc.deal_uuid, projectId);
|
||||
}
|
||||
return { projectId: projectId || null, documentId };
|
||||
}
|
||||
|
||||
@@ -419,6 +476,7 @@ module.exports = {
|
||||
assignEvent,
|
||||
assignQuote,
|
||||
assignContract,
|
||||
linkDealToProject,
|
||||
computeValuation,
|
||||
getProjectOverview,
|
||||
getEmailPreview,
|
||||
|
||||
@@ -561,6 +561,13 @@ async function createQuote(payload, adminId) {
|
||||
const inserted = await trx('quotes').insert(row).returning('id');
|
||||
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
// Cascade the project link across the deal lineage (no-op for a brand-new
|
||||
// quote with no contract/event yet — just adopts the customer onto an
|
||||
// empty project).
|
||||
if (row.project_id) {
|
||||
await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
|
||||
}
|
||||
|
||||
if (totals.lineItems.length > 0) {
|
||||
// Normalise rows for the hierarchical-insert helper. We preserve
|
||||
// the wire-only `parent_position` field here so the helper can
|
||||
@@ -677,6 +684,13 @@ async function updateQuote(id, payload, adminId) {
|
||||
}
|
||||
await trx('quotes').where({ id }).update(updates);
|
||||
|
||||
// When linked to a project, cascade across the deal lineage so the linked
|
||||
// contract / event / invoices roll up into the same project automatically.
|
||||
if (updates.project_id) {
|
||||
const dealRow = await trx('quotes').where({ id }).select('deal_uuid').first();
|
||||
await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
|
||||
}
|
||||
|
||||
// Delete + reinsert keeps the editor flow simple: the frontend
|
||||
// sends the canonical line-item set on every save, we drop the
|
||||
// old rows and rebuild from scratch. CASCADE on parent_line_item_id
|
||||
|
||||
Reference in New Issue
Block a user