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;
|
row.project_id = payload.projectId || null;
|
||||||
}
|
}
|
||||||
const inserted = await trx('contracts').insert(row).returning('id');
|
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];
|
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
|
||||||
// Seed with every active system block, toggled on. Per-section
|
// 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);
|
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.
|
// Replace inclusions only when the caller sent an explicit list.
|
||||||
// (Editor's "save" sends every row; an inline "toggle" save could
|
// (Editor's "save" sends every row; an inline "toggle" save could
|
||||||
// send a partial update — current frontend always sends full list.)
|
// send a partial update — current frontend always sends full list.)
|
||||||
|
|||||||
@@ -140,7 +140,61 @@ async function assignEvent(projectId, eventId) {
|
|||||||
return { 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) {
|
async function assignDocument(table, projectId, documentId) {
|
||||||
if (!(await hasColumnCached(table, 'project_id'))) {
|
if (!(await hasColumnCached(table, 'project_id'))) {
|
||||||
throw new AppError('This instance has no project_id column yet — run migrations', 409);
|
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();
|
const doc = await db(table).where({ id: documentId }).first();
|
||||||
if (!doc) throw new AppError('Document not found', 404);
|
if (!doc) throw new AppError('Document not found', 404);
|
||||||
await db(table).where({ id: documentId }).update({ project_id: projectId || null });
|
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 };
|
return { projectId: projectId || null, documentId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +476,7 @@ module.exports = {
|
|||||||
assignEvent,
|
assignEvent,
|
||||||
assignQuote,
|
assignQuote,
|
||||||
assignContract,
|
assignContract,
|
||||||
|
linkDealToProject,
|
||||||
computeValuation,
|
computeValuation,
|
||||||
getProjectOverview,
|
getProjectOverview,
|
||||||
getEmailPreview,
|
getEmailPreview,
|
||||||
|
|||||||
@@ -561,6 +561,13 @@ async function createQuote(payload, adminId) {
|
|||||||
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];
|
||||||
|
|
||||||
|
// 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) {
|
if (totals.lineItems.length > 0) {
|
||||||
// Normalise rows for the hierarchical-insert helper. We preserve
|
// Normalise rows for the hierarchical-insert helper. We preserve
|
||||||
// the wire-only `parent_position` field here so the helper can
|
// 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);
|
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
|
// Delete + reinsert keeps the editor flow simple: the frontend
|
||||||
// sends the canonical line-item set on every save, we drop the
|
// sends the canonical line-item set on every save, we drop the
|
||||||
// old rows and rebuild from scratch. CASCADE on parent_line_item_id
|
// old rows and rebuild from scratch. CASCADE on parent_line_item_id
|
||||||
|
|||||||
Reference in New Issue
Block a user