feat: restructure sales, add PDF quote generation and WebSocket support
This commit is contained in:
@@ -20,11 +20,13 @@ import {
|
||||
import {
|
||||
resolveMember,
|
||||
resolveMembers,
|
||||
getMemberCache,
|
||||
} from "../modules/cw-utils/members/memberCache";
|
||||
import { ForecastProductController } from "./ForecastProductController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
|
||||
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
|
||||
import UserController from "./UserController";
|
||||
import {
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
@@ -37,6 +39,11 @@ import {
|
||||
invalidateNotesCache,
|
||||
invalidateProductsCache,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
import {
|
||||
generateQuote as generateQuotePdf,
|
||||
type QuoteMetadata,
|
||||
} from "../modules/pdf-utils";
|
||||
import { generatedQuotes } from "../managers/generatedQuotes";
|
||||
|
||||
/**
|
||||
* Opportunity Controller
|
||||
@@ -81,6 +88,7 @@ export class OpportunityController {
|
||||
public customerPO: string | null;
|
||||
|
||||
public totalSalesTax: number;
|
||||
public probability: number;
|
||||
|
||||
public locationName: string | null;
|
||||
public locationCwId: number | null;
|
||||
@@ -131,6 +139,29 @@ export class OpportunityController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve primary sales rep info for quote generation.
|
||||
*
|
||||
* Looks up the primary sales rep in the CW member cache and returns
|
||||
* their name and email. Returns undefined if no rep is assigned.
|
||||
*/
|
||||
private async _resolveSalesRep(): Promise<
|
||||
{ name: string; email?: string } | undefined
|
||||
> {
|
||||
if (!this.primarySalesRepIdentifier) return undefined;
|
||||
const cache = await getMemberCache();
|
||||
const member = cache.get(this.primarySalesRepIdentifier);
|
||||
const name = member
|
||||
? `${member.firstName} ${member.lastName}`.trim() ||
|
||||
this.primarySalesRepName ||
|
||||
this.primarySalesRepIdentifier
|
||||
: (this.primarySalesRepName ?? this.primarySalesRepIdentifier);
|
||||
return {
|
||||
name,
|
||||
email: member?.officeEmail ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
data: Opportunity & { company?: Company | null },
|
||||
opts?: {
|
||||
@@ -174,6 +205,7 @@ export class OpportunityController {
|
||||
this.customerPO = data.customerPO;
|
||||
|
||||
this.totalSalesTax = data.totalSalesTax;
|
||||
this.probability = data.probability;
|
||||
|
||||
this.locationName = data.locationName;
|
||||
this.locationCwId = data.locationCwId;
|
||||
@@ -203,6 +235,18 @@ export class OpportunityController {
|
||||
this._activities = opts?.activities ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate Custom Fields
|
||||
*
|
||||
* Lazily fetches the opportunity's custom fields from ConnectWise
|
||||
* if they haven't been loaded yet.
|
||||
*/
|
||||
private async _hydrateCustomFields(): Promise<void> {
|
||||
if (this._customFields !== null) return;
|
||||
const cwData = await fetchOpportunity(this.cwOpportunityId);
|
||||
this._customFields = cwData.customFields ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company
|
||||
*
|
||||
@@ -297,6 +341,7 @@ export class OpportunityController {
|
||||
customerPO: item.customerPO ?? null,
|
||||
|
||||
totalSalesTax: item.totalSalesTax ?? 0,
|
||||
probability: Number(item.probability?.name) || 0,
|
||||
|
||||
locationName: item.location?.name ?? null,
|
||||
locationCwId: item.location?.id ?? null,
|
||||
@@ -536,6 +581,372 @@ export class OpportunityController {
|
||||
return this._buildProductControllers(forecast, procProducts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Quote PDF
|
||||
*
|
||||
* Builds a customer-facing quote PDF using the opportunity, company, site,
|
||||
* and product data available to this controller.
|
||||
*/
|
||||
public async generateQuote(opts?: {
|
||||
lineItemPricing?: boolean;
|
||||
includeQuoteNarrative?: boolean;
|
||||
includeItemNarratives?: boolean;
|
||||
showPreview?: boolean; // INTERNAL ONLY
|
||||
logoPath?: string;
|
||||
metadata?: QuoteMetadata;
|
||||
}): Promise<Buffer> {
|
||||
const options = {
|
||||
lineItemPricing: opts?.lineItemPricing ?? true,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
|
||||
includeItemNarratives: opts?.includeItemNarratives ?? true,
|
||||
showPreview: opts?.showPreview ?? false,
|
||||
logoPath: opts?.logoPath,
|
||||
};
|
||||
|
||||
const products = await this.fetchProducts();
|
||||
const activeProducts = products.filter(
|
||||
(item) => item.includeFlag && item.cancellationType !== "full",
|
||||
);
|
||||
|
||||
if (activeProducts.length === 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "QuoteGenerationError",
|
||||
message: "Cannot generate a quote with no included line items",
|
||||
});
|
||||
}
|
||||
|
||||
const company = await this.fetchCompany();
|
||||
const companyJson = company?.toJson({
|
||||
includeAddress: true,
|
||||
includePrimaryContact: true,
|
||||
includeAllContacts: false,
|
||||
});
|
||||
const site = await this.fetchSite();
|
||||
|
||||
const siteAddress = [
|
||||
site?.address?.line1,
|
||||
site?.address?.line2,
|
||||
[site?.address?.city, site?.address?.state, site?.address?.zip]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const companyAddress = [
|
||||
companyJson?.cw_Data?.address?.line1,
|
||||
companyJson?.cw_Data?.address?.line2,
|
||||
[
|
||||
companyJson?.cw_Data?.address?.city,
|
||||
companyJson?.cw_Data?.address?.state,
|
||||
companyJson?.cw_Data?.address?.zip,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const addressLines = siteAddress.length > 0 ? siteAddress : companyAddress;
|
||||
|
||||
const lineItems = activeProducts.map((item) => {
|
||||
const isLabor = item.productClass === "Service";
|
||||
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
|
||||
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
|
||||
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
|
||||
|
||||
const itemNarrative = item.productNarrative || null;
|
||||
|
||||
const shouldIncludeNarrative =
|
||||
options.includeItemNarratives && !!itemNarrative;
|
||||
|
||||
return {
|
||||
qty: isLabor ? 1 : quantity,
|
||||
description: item.productDescription || "Line Item",
|
||||
unitPrice,
|
||||
narrative: shouldIncludeNarrative ? itemNarrative : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const quoteDescription = this.name;
|
||||
|
||||
const primaryContactFullName = [
|
||||
companyJson?.cw_Data?.primaryContact?.firstName,
|
||||
companyJson?.cw_Data?.primaryContact?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
const customerName =
|
||||
this.contactName ||
|
||||
primaryContactFullName ||
|
||||
this.companyName ||
|
||||
"Customer";
|
||||
|
||||
const subTotal = lineItems.reduce(
|
||||
(sum, item) => sum + item.qty * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
const normalizedTaxRate =
|
||||
subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0;
|
||||
const taxLabel =
|
||||
normalizedTaxRate > 0
|
||||
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
|
||||
: "Sales Tax";
|
||||
|
||||
await this._hydrateCustomFields();
|
||||
|
||||
const quoteNarrative = options.includeQuoteNarrative
|
||||
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
||||
undefined
|
||||
: undefined;
|
||||
|
||||
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
|
||||
|
||||
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
||||
|
||||
// Only show attention if it differs from the customer name
|
||||
const attention =
|
||||
this.contactName && this.contactName !== customerName
|
||||
? this.contactName
|
||||
: undefined;
|
||||
|
||||
// Only show company if it's meaningfully different from the customer name
|
||||
// (catches "Patterson, Diane" vs "Diane Patterson" style duplicates)
|
||||
const normalise = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[,.\s]+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.sort()
|
||||
.join(" ");
|
||||
const showCompany = normalise(companyLine) !== normalise(customerName);
|
||||
|
||||
return generateQuotePdf(
|
||||
{
|
||||
customer: {
|
||||
name: customerName,
|
||||
company: showCompany ? companyLine : undefined,
|
||||
attention,
|
||||
address:
|
||||
addressLines.length > 0 ? addressLines : ["Address unavailable"],
|
||||
},
|
||||
contact: {
|
||||
email: companyJson?.cw_Data?.primaryContact?.email ?? undefined,
|
||||
phone: companyJson?.cw_Data?.primaryContact?.phone ?? undefined,
|
||||
},
|
||||
salesRep: await this._resolveSalesRep(),
|
||||
quote: {
|
||||
quoteNumber: this.cwOpportunityId.toString(),
|
||||
date: new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
description: quoteDescription,
|
||||
},
|
||||
lineItems,
|
||||
quoteNarrative,
|
||||
tax: {
|
||||
rate: normalizedTaxRate,
|
||||
label: taxLabel,
|
||||
},
|
||||
isPreview: options.showPreview,
|
||||
showLineItemPricing: options.lineItemPricing,
|
||||
metadata: opts?.metadata,
|
||||
},
|
||||
{},
|
||||
options.logoPath,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit Quote
|
||||
*
|
||||
* Generates a non-preview quote PDF and stores it in the GeneratedQuotes
|
||||
* table with a full data snapshot for exact reproduction, regeneration
|
||||
* metadata, and creator attribution.
|
||||
*/
|
||||
public async commitQuote(
|
||||
opts: {
|
||||
lineItemPricing?: boolean;
|
||||
includeQuoteNarrative?: boolean;
|
||||
includeItemNarratives?: boolean;
|
||||
logoPath?: string;
|
||||
} = {},
|
||||
user: UserController,
|
||||
) {
|
||||
const quoteOptions = {
|
||||
lineItemPricing: opts?.lineItemPricing ?? true,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
|
||||
includeItemNarratives: opts?.includeItemNarratives ?? true,
|
||||
logoPath: opts?.logoPath,
|
||||
};
|
||||
|
||||
// ── Fetch all data sources BEFORE generating ──────────────────────
|
||||
const products = await this.fetchProducts();
|
||||
const company = await this.fetchCompany();
|
||||
const companyJson = company?.toJson({
|
||||
includeAddress: true,
|
||||
includePrimaryContact: true,
|
||||
includeAllContacts: false,
|
||||
});
|
||||
const site = await this.fetchSite();
|
||||
const salesRep = await this._resolveSalesRep();
|
||||
await this._hydrateCustomFields();
|
||||
|
||||
const quoteNarrative = quoteOptions.includeQuoteNarrative
|
||||
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
|
||||
null)
|
||||
: null;
|
||||
|
||||
// ── Pre-generate IDs & timestamps for metadata ───────────────────
|
||||
const quoteId = crypto.randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
// ── Generate the PDF ──────────────────────────────────────────────
|
||||
const quoteBuffer = await this.generateQuote({
|
||||
...quoteOptions,
|
||||
showPreview: false,
|
||||
metadata: {
|
||||
quoteId,
|
||||
createdById: user.id,
|
||||
createdByName: user.name ?? undefined,
|
||||
createdByEmail: user.email ?? undefined,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
const fileTimestamp = createdAt.replace(/[:.]/g, "-");
|
||||
const quoteFileName = `OPP-${this.cwOpportunityId}-${fileTimestamp}.pdf`;
|
||||
|
||||
// ── Build the full data snapshot ──────────────────────────────────
|
||||
const siteAddress = [
|
||||
site?.address?.line1,
|
||||
site?.address?.line2,
|
||||
[site?.address?.city, site?.address?.state, site?.address?.zip]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const companyAddress = [
|
||||
companyJson?.cw_Data?.address?.line1,
|
||||
companyJson?.cw_Data?.address?.line2,
|
||||
[
|
||||
companyJson?.cw_Data?.address?.city,
|
||||
companyJson?.cw_Data?.address?.state,
|
||||
companyJson?.cw_Data?.address?.zip,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const primaryContactFullName = [
|
||||
companyJson?.cw_Data?.primaryContact?.firstName,
|
||||
companyJson?.cw_Data?.primaryContact?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
const regenData = {
|
||||
// Generation options
|
||||
options: {
|
||||
lineItemPricing: quoteOptions.lineItemPricing,
|
||||
includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
|
||||
includeItemNarratives: quoteOptions.includeItemNarratives,
|
||||
},
|
||||
|
||||
// Opportunity metadata
|
||||
opportunity: {
|
||||
id: this.id,
|
||||
cwOpportunityId: this.cwOpportunityId,
|
||||
name: this.name,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
contactName: this.contactName,
|
||||
companyName: this.companyName,
|
||||
},
|
||||
|
||||
// Customer / company / site snapshot
|
||||
customer: {
|
||||
preparedFor:
|
||||
this.contactName ||
|
||||
primaryContactFullName ||
|
||||
this.companyName ||
|
||||
"Customer",
|
||||
companyName: this.companyName ?? company?.name ?? null,
|
||||
primaryContact: companyJson?.cw_Data?.primaryContact
|
||||
? {
|
||||
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
|
||||
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
|
||||
email: companyJson.cw_Data.primaryContact.email ?? null,
|
||||
phone: companyJson.cw_Data.primaryContact.phone ?? null,
|
||||
}
|
||||
: null,
|
||||
siteAddress: siteAddress.length > 0 ? siteAddress : null,
|
||||
companyAddress: companyAddress.length > 0 ? companyAddress : null,
|
||||
},
|
||||
|
||||
// Sales rep snapshot
|
||||
salesRep: salesRep ?? null,
|
||||
|
||||
// Quote narrative
|
||||
quoteNarrative: quoteNarrative ?? null,
|
||||
|
||||
// Full product snapshot
|
||||
products: products.map((p) => ({
|
||||
cwForecastId: p.cwForecastId,
|
||||
forecastDescription: p.forecastDescription,
|
||||
productDescription: p.productDescription,
|
||||
customerDescription: p.customerDescription,
|
||||
productNarrative: p.productNarrative,
|
||||
productClass: p.productClass,
|
||||
forecastType: p.forecastType,
|
||||
catalogItem: p.catalogItemCwId
|
||||
? { id: p.catalogItemCwId, identifier: p.catalogItemIdentifier }
|
||||
: null,
|
||||
quantity: p.quantity,
|
||||
effectiveQuantity: p.effectiveQuantity,
|
||||
revenue: p.revenue,
|
||||
cost: p.cost,
|
||||
margin: p.margin,
|
||||
percentage: p.percentage,
|
||||
includeFlag: p.includeFlag,
|
||||
taxableFlag: p.taxableFlag,
|
||||
recurringFlag: p.recurringFlag,
|
||||
recurringRevenue: p.recurringRevenue,
|
||||
recurringCost: p.recurringCost,
|
||||
sequenceNumber: p.sequenceNumber,
|
||||
cancelledFlag: p.cancelledFlag,
|
||||
cancellationType: p.cancellationType,
|
||||
quantityCancelled: p.quantityCancelled,
|
||||
cancelledReason: p.cancelledReason,
|
||||
cancelledDate: p.cancelledDate,
|
||||
})),
|
||||
|
||||
// Timestamp of when this snapshot was taken
|
||||
snapshotTimestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const regenParams = {
|
||||
opportunityId: this.id,
|
||||
cwOpportunityId: this.cwOpportunityId,
|
||||
};
|
||||
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(JSON.stringify({ regenData, regenParams }));
|
||||
const quoteRegenHash = hasher.digest("hex");
|
||||
|
||||
return generatedQuotes.create({
|
||||
id: quoteId,
|
||||
quoteRegenData: regenData,
|
||||
quoteRegenParams: regenParams,
|
||||
quoteRegenHash,
|
||||
quoteFile: quoteBuffer,
|
||||
quoteFileName,
|
||||
opportunityId: this.id,
|
||||
createdById: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ForecastProductController[] from raw CW data.
|
||||
*
|
||||
@@ -593,6 +1004,7 @@ export class OpportunityController {
|
||||
const procData = cancellationMap.get(item.id);
|
||||
if (procData) {
|
||||
ctrl.applyCancellationData(procData as any);
|
||||
ctrl.applyProcurementCustomFields(procData as any);
|
||||
}
|
||||
return ctrl;
|
||||
},
|
||||
@@ -1115,6 +1527,7 @@ export class OpportunityController {
|
||||
: null,
|
||||
customerPO: this.customerPO,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
probability: this.probability,
|
||||
location: this.locationCwId
|
||||
? { id: this.locationCwId, name: this.locationName }
|
||||
: null,
|
||||
|
||||
Reference in New Issue
Block a user