1609 lines
50 KiB
TypeScript
1609 lines
50 KiB
TypeScript
import { Company, Opportunity } from "../../generated/prisma/client";
|
|
import { prisma } from "../constants";
|
|
import { CompanyController } from "./CompanyController";
|
|
import { ActivityController } from "./ActivityController";
|
|
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
|
|
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
|
import { activityCw } from "../modules/cw-utils/activities/activities";
|
|
import {
|
|
fetchCompanySite,
|
|
serializeCwSite,
|
|
} from "../modules/cw-utils/sites/companySites";
|
|
import {
|
|
CWCustomField,
|
|
CWForecastItemCreate,
|
|
CWOpportunity,
|
|
CWOpportunityNote,
|
|
CWOpportunityUpdate,
|
|
CWProcurementProduct,
|
|
CWProcurementProductCreate,
|
|
} from "../modules/cw-utils/opportunities/opportunity.types";
|
|
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,
|
|
getCachedProducts,
|
|
getCachedSite,
|
|
fetchAndCacheNotes,
|
|
fetchAndCacheContacts,
|
|
fetchAndCacheProducts,
|
|
fetchAndCacheSite,
|
|
invalidateNotesCache,
|
|
invalidateProductsCache,
|
|
} from "../modules/cache/opportunityCache";
|
|
import {
|
|
generateQuote as generateQuotePdf,
|
|
type QuoteMetadata,
|
|
} from "../modules/pdf-utils";
|
|
import { generatedQuotes } from "../managers/generatedQuotes";
|
|
|
|
/**
|
|
* Opportunity Controller
|
|
*
|
|
* Domain model class that encapsulates an Opportunity entity and provides
|
|
* methods for accessing, refreshing from ConnectWise, and serializing
|
|
* opportunity data.
|
|
*/
|
|
export class OpportunityController {
|
|
public readonly id: string;
|
|
public readonly cwOpportunityId: number;
|
|
public name: string;
|
|
public notes: string | null;
|
|
|
|
public typeName: string | null;
|
|
public typeCwId: number | null;
|
|
public stageName: string | null;
|
|
public stageCwId: number | null;
|
|
public statusName: string | null;
|
|
public statusCwId: number | null;
|
|
public priorityName: string | null;
|
|
public priorityCwId: number | null;
|
|
public ratingName: string | null;
|
|
public ratingCwId: number | null;
|
|
public source: string | null;
|
|
public campaignName: string | null;
|
|
public campaignCwId: number | null;
|
|
|
|
public primarySalesRepName: string | null;
|
|
public primarySalesRepIdentifier: string | null;
|
|
public primarySalesRepCwId: number | null;
|
|
public secondarySalesRepName: string | null;
|
|
public secondarySalesRepIdentifier: string | null;
|
|
public secondarySalesRepCwId: number | null;
|
|
|
|
public companyCwId: number | null;
|
|
public companyName: string | null;
|
|
public contactCwId: number | null;
|
|
public contactName: string | null;
|
|
public siteCwId: number | null;
|
|
public siteName: string | null;
|
|
public customerPO: string | null;
|
|
|
|
public totalSalesTax: number;
|
|
public probability: number;
|
|
|
|
public locationName: string | null;
|
|
public locationCwId: number | null;
|
|
public departmentName: string | null;
|
|
public departmentCwId: number | null;
|
|
|
|
public expectedCloseDate: Date | null;
|
|
public pipelineChangeDate: Date | null;
|
|
public dateBecameLead: Date | null;
|
|
public closedDate: Date | null;
|
|
public closedFlag: boolean;
|
|
public closedByName: string | null;
|
|
public closedByCwId: number | null;
|
|
|
|
public companyId: string | null;
|
|
public cwLastUpdated: Date | null;
|
|
|
|
// Local product display order — array of CW forecast item IDs.
|
|
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
|
|
public productSequence: number[];
|
|
|
|
public readonly createdAt: Date;
|
|
public updatedAt: Date;
|
|
|
|
private _company: CompanyController | null = null;
|
|
private _siteData: ReturnType<typeof serializeCwSite> | null = null;
|
|
private _customFields: CWCustomField[] | null = null;
|
|
private _activities: ActivityController[] | null = null;
|
|
|
|
/** Compute the sub-resource cache TTL from this opportunity's fields. */
|
|
private _subResourceTTL(): number | null {
|
|
return computeSubResourceCacheTTL({
|
|
closedFlag: this.closedFlag,
|
|
closedDate: this.closedDate,
|
|
expectedCloseDate: this.expectedCloseDate,
|
|
lastUpdated: this.cwLastUpdated,
|
|
});
|
|
}
|
|
|
|
/** Compute the products-specific cache TTL from this opportunity's fields. */
|
|
private _productsTTL(): number | null {
|
|
return computeProductsCacheTTL({
|
|
closedFlag: this.closedFlag,
|
|
closedDate: this.closedDate,
|
|
expectedCloseDate: this.expectedCloseDate,
|
|
lastUpdated: this.cwLastUpdated,
|
|
statusCwId: this.statusCwId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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?: {
|
|
company?: CompanyController;
|
|
customFields?: CWCustomField[];
|
|
activities?: ActivityController[];
|
|
},
|
|
) {
|
|
this.id = data.id;
|
|
this.cwOpportunityId = data.cwOpportunityId;
|
|
this.name = data.name;
|
|
this.notes = data.notes;
|
|
|
|
this.typeName = data.typeName;
|
|
this.typeCwId = data.typeCwId;
|
|
this.stageName = data.stageName;
|
|
this.stageCwId = data.stageCwId;
|
|
this.statusName = data.statusName;
|
|
this.statusCwId = data.statusCwId;
|
|
this.priorityName = data.priorityName;
|
|
this.priorityCwId = data.priorityCwId;
|
|
this.ratingName = data.ratingName;
|
|
this.ratingCwId = data.ratingCwId;
|
|
this.source = data.source;
|
|
this.campaignName = data.campaignName;
|
|
this.campaignCwId = data.campaignCwId;
|
|
|
|
this.primarySalesRepName = data.primarySalesRepName;
|
|
this.primarySalesRepIdentifier = data.primarySalesRepIdentifier;
|
|
this.primarySalesRepCwId = data.primarySalesRepCwId;
|
|
this.secondarySalesRepName = data.secondarySalesRepName;
|
|
this.secondarySalesRepIdentifier = data.secondarySalesRepIdentifier;
|
|
this.secondarySalesRepCwId = data.secondarySalesRepCwId;
|
|
|
|
this.companyCwId = data.companyCwId;
|
|
this.companyName = data.companyName;
|
|
this.contactCwId = data.contactCwId;
|
|
this.contactName = data.contactName;
|
|
this.siteCwId = data.siteCwId;
|
|
this.siteName = data.siteName;
|
|
this.customerPO = data.customerPO;
|
|
|
|
this.totalSalesTax = data.totalSalesTax;
|
|
this.probability = data.probability;
|
|
|
|
this.locationName = data.locationName;
|
|
this.locationCwId = data.locationCwId;
|
|
this.departmentName = data.departmentName;
|
|
this.departmentCwId = data.departmentCwId;
|
|
|
|
this.expectedCloseDate = data.expectedCloseDate;
|
|
this.pipelineChangeDate = data.pipelineChangeDate;
|
|
this.dateBecameLead = data.dateBecameLead;
|
|
this.closedDate = data.closedDate;
|
|
this.closedFlag = data.closedFlag;
|
|
this.closedByName = data.closedByName;
|
|
this.closedByCwId = data.closedByCwId;
|
|
|
|
this.companyId = data.companyId;
|
|
this.cwLastUpdated = data.cwLastUpdated;
|
|
this.productSequence = data.productSequence;
|
|
|
|
this.createdAt = data.createdAt;
|
|
this.updatedAt = data.updatedAt;
|
|
|
|
this._company =
|
|
opts?.company ??
|
|
(data.company ? new CompanyController(data.company) : null);
|
|
|
|
this._customFields = opts?.customFields ?? null;
|
|
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
|
|
*
|
|
* Lazily loads the associated CompanyController from the database
|
|
* if not already loaded via the Prisma include.
|
|
*
|
|
* @returns {Promise<CompanyController | null>}
|
|
*/
|
|
public async fetchCompany(): Promise<CompanyController | null> {
|
|
if (this._company) {
|
|
await this._company.hydrateCwData();
|
|
return this._company;
|
|
}
|
|
if (!this.companyId) return null;
|
|
|
|
const companyData = await prisma.company.findUnique({
|
|
where: { id: this.companyId },
|
|
});
|
|
|
|
if (!companyData) return null;
|
|
|
|
this._company = new CompanyController(companyData);
|
|
await this._company.hydrateCwData();
|
|
return this._company;
|
|
}
|
|
|
|
/**
|
|
* Refresh from ConnectWise
|
|
*
|
|
* Fetches the latest opportunity data from CW and updates
|
|
* the local database record and controller state.
|
|
*/
|
|
public async refreshFromCW(): Promise<OpportunityController> {
|
|
const cwData = await fetchOpportunity(this.cwOpportunityId);
|
|
const mapped = OpportunityController.mapCwToDb(cwData);
|
|
|
|
const updated = await prisma.opportunity.update({
|
|
where: { id: this.id },
|
|
data: mapped,
|
|
include: { company: true },
|
|
});
|
|
|
|
return new OpportunityController(updated);
|
|
}
|
|
|
|
/**
|
|
* Update Opportunity
|
|
*
|
|
* Patches the opportunity in ConnectWise with the provided fields,
|
|
* then syncs the updated data back to the local database.
|
|
*
|
|
* @param data — Partial fields to update on the CW opportunity
|
|
* @returns A fresh OpportunityController with the updated data
|
|
*/
|
|
public async updateOpportunity(
|
|
data: CWOpportunityUpdate,
|
|
): Promise<OpportunityController> {
|
|
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
|
|
const mapped = OpportunityController.mapCwToDb(cwData);
|
|
|
|
const updated = await prisma.opportunity.update({
|
|
where: { id: this.id },
|
|
data: mapped,
|
|
include: { company: true },
|
|
});
|
|
|
|
return new OpportunityController(updated);
|
|
}
|
|
|
|
/**
|
|
* Fetch raw CW data
|
|
*
|
|
* Returns the raw ConnectWise opportunity object without updating the DB.
|
|
*/
|
|
public async fetchCwData(): Promise<CWOpportunity> {
|
|
return fetchOpportunity(this.cwOpportunityId);
|
|
}
|
|
|
|
/**
|
|
* Map CW Opportunity → Prisma create/update payload
|
|
*
|
|
* Static helper used by both the controller and the refresh sync.
|
|
*/
|
|
public static mapCwToDb(item: CWOpportunity) {
|
|
return {
|
|
name: item.name,
|
|
notes: item.notes ?? null,
|
|
|
|
typeName: item.type?.name ?? null,
|
|
typeCwId: item.type?.id ?? null,
|
|
stageName: item.stage?.name ?? null,
|
|
stageCwId: item.stage?.id ?? null,
|
|
statusName: item.status?.name ?? null,
|
|
statusCwId: item.status?.id ?? null,
|
|
priorityName: item.priority?.name ?? null,
|
|
priorityCwId: item.priority?.id ?? null,
|
|
ratingName: item.rating?.name ?? null,
|
|
ratingCwId: item.rating?.id ?? null,
|
|
source: item.source ?? null,
|
|
campaignName: item.campaign?.name ?? null,
|
|
campaignCwId: item.campaign?.id ?? null,
|
|
|
|
primarySalesRepName: item.primarySalesRep?.name ?? null,
|
|
primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null,
|
|
primarySalesRepCwId: item.primarySalesRep?.id ?? null,
|
|
secondarySalesRepName: item.secondarySalesRep?.name ?? null,
|
|
secondarySalesRepIdentifier: item.secondarySalesRep?.identifier ?? null,
|
|
secondarySalesRepCwId: item.secondarySalesRep?.id ?? null,
|
|
|
|
companyCwId: item.company?.id ?? null,
|
|
companyName: item.company?.name ?? null,
|
|
contactCwId: item.contact?.id ?? null,
|
|
contactName: item.contact?.name ?? null,
|
|
siteCwId: item.site?.id ?? null,
|
|
siteName: item.site?.name ?? null,
|
|
customerPO: item.customerPO ?? null,
|
|
|
|
totalSalesTax: item.totalSalesTax ?? 0,
|
|
probability: Number(item.probability?.name) || 0,
|
|
|
|
locationName: item.location?.name ?? null,
|
|
locationCwId: item.location?.id ?? null,
|
|
departmentName: item.department?.name ?? null,
|
|
departmentCwId: item.department?.id ?? null,
|
|
|
|
expectedCloseDate: item.expectedCloseDate
|
|
? new Date(item.expectedCloseDate)
|
|
: null,
|
|
pipelineChangeDate: item.pipelineChangeDate
|
|
? new Date(item.pipelineChangeDate)
|
|
: null,
|
|
dateBecameLead: item.dateBecameLead
|
|
? new Date(item.dateBecameLead)
|
|
: null,
|
|
closedDate: item.closedDate ? new Date(item.closedDate) : null,
|
|
closedFlag: item.closedFlag ?? false,
|
|
closedByName: item.closedBy?.name ?? null,
|
|
closedByCwId: item.closedBy?.id ?? null,
|
|
|
|
cwLastUpdated: item._info?.lastUpdated
|
|
? new Date(item._info.lastUpdated)
|
|
: new Date(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch Site
|
|
*
|
|
* Fetches the full site details (address, phone, flags) from ConnectWise
|
|
* for the site associated with this opportunity.
|
|
* Checks the Redis cache first (30-min TTL); on miss, calls CW and caches.
|
|
* Requires both companyCwId and siteCwId to be set.
|
|
*
|
|
* @returns Serialized site object or null
|
|
*/
|
|
public async fetchSite() {
|
|
if (this._siteData) return this._siteData;
|
|
if (!this.companyCwId || !this.siteCwId) return null;
|
|
|
|
// Try cache first
|
|
const cached = await getCachedSite(this.companyCwId, this.siteCwId);
|
|
if (cached) {
|
|
this._siteData = serializeCwSite(cached);
|
|
return this._siteData;
|
|
}
|
|
|
|
// Cache miss — fetch from CW and cache
|
|
const cwSite = await fetchAndCacheSite(this.companyCwId, this.siteCwId);
|
|
if (!cwSite) return null;
|
|
|
|
this._siteData = serializeCwSite(cwSite);
|
|
return this._siteData;
|
|
}
|
|
|
|
/**
|
|
* Fetch Contacts
|
|
*
|
|
* Fetches contacts associated with this opportunity. Checks the Redis
|
|
* cache first; on miss, calls ConnectWise and caches the raw response.
|
|
*
|
|
* @param opts.fresh - Bypass cache and fetch directly from CW.
|
|
*/
|
|
public async fetchContacts(opts?: { fresh?: boolean }) {
|
|
const ttl = this._subResourceTTL();
|
|
|
|
// Try cache first (unless forced fresh)
|
|
if (!opts?.fresh && ttl !== null) {
|
|
const cached = await getCachedContacts(this.cwOpportunityId);
|
|
if (cached) return this._serializeContacts(cached);
|
|
}
|
|
|
|
// Fetch from CW (fetchAndCache* handles 404 internally)
|
|
try {
|
|
const contacts =
|
|
ttl !== null
|
|
? await fetchAndCacheContacts(this.cwOpportunityId, ttl)
|
|
: await opportunityCw.fetchContacts(this.cwOpportunityId);
|
|
|
|
return this._serializeContacts(contacts);
|
|
} catch (err: any) {
|
|
if (err?.isAxiosError && err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Serialize raw CW contact data into the API response shape. */
|
|
private _serializeContacts(contacts: any[]) {
|
|
return contacts.map((ct: any) => ({
|
|
id: ct.id,
|
|
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
|
|
company: ct.company
|
|
? {
|
|
id: ct.company.id,
|
|
identifier: ct.company.identifier,
|
|
name: ct.company.name,
|
|
}
|
|
: null,
|
|
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
|
|
notes: ct.notes,
|
|
referralFlag: ct.referralFlag,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Fetch Notes
|
|
*
|
|
* Fetches notes associated with this opportunity. Checks the Redis
|
|
* cache first; on miss, calls ConnectWise and caches the raw response.
|
|
*
|
|
* @param opts.fresh - Bypass cache and fetch directly from CW.
|
|
*/
|
|
public async fetchNotes(opts?: { fresh?: boolean }) {
|
|
const ttl = this._subResourceTTL();
|
|
|
|
// Try cache first (unless forced fresh)
|
|
if (!opts?.fresh && ttl !== null) {
|
|
const cached = await getCachedNotes(this.cwOpportunityId);
|
|
if (cached) return this._serializeNotes(cached);
|
|
}
|
|
|
|
// Fetch from CW (fetchAndCache* handles 404 internally)
|
|
try {
|
|
const notes =
|
|
ttl !== null
|
|
? await fetchAndCacheNotes(this.cwOpportunityId, ttl)
|
|
: await opportunityCw.fetchNotes(this.cwOpportunityId);
|
|
|
|
return this._serializeNotes(notes);
|
|
} catch (err: any) {
|
|
if (err?.isAxiosError && err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Serialize raw CW note data into the API response shape. */
|
|
private async _serializeNotes(notes: any[]) {
|
|
// Batch-resolve all member identifiers in a single DB query
|
|
const identifiers = notes
|
|
.map((n: any) => n.enteredBy as string)
|
|
.filter(Boolean);
|
|
const memberMap = await resolveMembers(identifiers);
|
|
|
|
return notes.map((n: any) => ({
|
|
id: n.id,
|
|
text: n.text,
|
|
type: n.type ? { id: n.type.id, name: n.type.name } : null,
|
|
flagged: n.flagged,
|
|
dateEntered: n._info?.lastUpdated ? new Date(n._info.lastUpdated) : null,
|
|
enteredBy: memberMap.get(n.enteredBy) ?? {
|
|
id: null,
|
|
identifier: n.enteredBy,
|
|
name: n.enteredBy,
|
|
cwMemberId: null,
|
|
},
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Fetch Single Note
|
|
*
|
|
* Fetches a single note by its ID from ConnectWise.
|
|
*
|
|
* @param noteId - The CW note ID
|
|
*/
|
|
public async fetchNote(noteId: number) {
|
|
const note = await opportunityCw.fetchNote(this.cwOpportunityId, noteId);
|
|
|
|
return {
|
|
id: note.id,
|
|
text: note.text,
|
|
type: note.type ? { id: note.type.id, name: note.type.name } : null,
|
|
flagged: note.flagged,
|
|
enteredBy: await resolveMember(note.enteredBy),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch Activities
|
|
*
|
|
* Fetches activities associated with this opportunity from ConnectWise
|
|
* and returns an array of ActivityController instances.
|
|
* Results are cached after the first call.
|
|
*/
|
|
public async fetchActivities(): Promise<ActivityController[]> {
|
|
if (this._activities) return this._activities;
|
|
|
|
const collection = await activityCw.fetchByOpportunity(
|
|
this.cwOpportunityId,
|
|
);
|
|
this._activities = collection.map((item) => new ActivityController(item));
|
|
return this._activities;
|
|
}
|
|
|
|
/**
|
|
* Fetch Products
|
|
*
|
|
* Fetches products (forecast/revenue items) for this opportunity.
|
|
* Checks the Redis cache first; on miss, calls ConnectWise and
|
|
* caches the raw response using the products-specific TTL algorithm.
|
|
*
|
|
* @param opts.fresh - Bypass cache and fetch directly from CW.
|
|
*/
|
|
public async fetchProducts(opts?: {
|
|
fresh?: boolean;
|
|
}): Promise<ForecastProductController[]> {
|
|
const ttl = this._productsTTL();
|
|
|
|
let forecast: any;
|
|
let procProducts: any[];
|
|
|
|
// Try cache first (unless forced fresh)
|
|
if (!opts?.fresh && ttl !== null) {
|
|
const cached = await getCachedProducts(this.cwOpportunityId);
|
|
if (cached) {
|
|
forecast = cached.forecast;
|
|
procProducts = cached.procProducts;
|
|
} else {
|
|
// Cache miss — fetch from CW and cache
|
|
const blob = await fetchAndCacheProducts(this.cwOpportunityId, ttl);
|
|
forecast = blob.forecast;
|
|
procProducts = blob.procProducts;
|
|
}
|
|
} else {
|
|
// No caching (won/lost/pending or forced fresh) — fetch directly
|
|
try {
|
|
[forecast, procProducts] = await Promise.all([
|
|
opportunityCw.fetchProducts(this.cwOpportunityId),
|
|
opportunityCw.fetchProcurementProducts(this.cwOpportunityId),
|
|
]);
|
|
} catch (err: any) {
|
|
if (err?.isAxiosError && err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
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.
|
|
*
|
|
* Extracted from fetchProducts() so both cached and fresh paths
|
|
* share the same ordering + enrichment logic.
|
|
*/
|
|
private async _buildProductControllers(
|
|
forecast: any,
|
|
procProducts: any[],
|
|
): Promise<ForecastProductController[]> {
|
|
// Build a map of forecastDetailId → procurement product cancellation data
|
|
const cancellationMap = new Map<number, Record<string, unknown>>();
|
|
for (const pp of procProducts) {
|
|
const rawForecastDetailId = (pp as any)?.forecastDetailId;
|
|
const forecastDetailId =
|
|
typeof rawForecastDetailId === "number"
|
|
? rawForecastDetailId
|
|
: Number(rawForecastDetailId);
|
|
|
|
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
|
|
cancellationMap.set(forecastDetailId, pp);
|
|
}
|
|
}
|
|
|
|
// Procurement-only view: only include forecast items that have a
|
|
// matching procurement record (via forecastDetailId).
|
|
const forecastItems = (forecast.forecastItems ?? []).filter((fi: any) =>
|
|
cancellationMap.has(fi.id),
|
|
);
|
|
|
|
// Apply local ordering if productSequence is set, otherwise fall back
|
|
// to CW sequenceNumber.
|
|
let ordered: typeof forecastItems;
|
|
|
|
if (this.productSequence.length > 0) {
|
|
const itemById = new Map(forecastItems.map((fi: any) => [fi.id, fi]));
|
|
// Items in the specified order first, then any new items not yet sequenced
|
|
const sequenced = this.productSequence
|
|
.map((id) => itemById.get(id))
|
|
.filter((fi: any): fi is NonNullable<typeof fi> => fi !== undefined);
|
|
const sequencedIds = new Set(this.productSequence);
|
|
const unsequenced = forecastItems
|
|
.filter((fi: any) => !sequencedIds.has(fi.id))
|
|
.sort((a: any, b: any) => a.sequenceNumber - b.sequenceNumber);
|
|
ordered = [...sequenced, ...unsequenced];
|
|
} else {
|
|
ordered = [...forecastItems].sort(
|
|
(a: any, b: any) => a.sequenceNumber - b.sequenceNumber,
|
|
);
|
|
}
|
|
|
|
const controllers: ForecastProductController[] = ordered.map(
|
|
(item: any) => {
|
|
const ctrl = new ForecastProductController(item);
|
|
const procData = cancellationMap.get(item.id);
|
|
if (procData) {
|
|
ctrl.applyCancellationData(procData as any);
|
|
ctrl.applyProcurementCustomFields(procData as any);
|
|
}
|
|
return ctrl;
|
|
},
|
|
);
|
|
|
|
// Enrich with internal inventory data from local CatalogItem DB
|
|
const catalogCwIds = controllers
|
|
.map((c) => c.catalogItemCwId)
|
|
.filter((id): id is number => id !== null);
|
|
|
|
if (catalogCwIds.length > 0) {
|
|
const catalogItems = await prisma.catalogItem.findMany({
|
|
where: { cwCatalogId: { in: catalogCwIds } },
|
|
select: { cwCatalogId: true, onHand: true },
|
|
});
|
|
const inventoryMap = new Map(
|
|
catalogItems.map((ci) => [ci.cwCatalogId, ci]),
|
|
);
|
|
for (const ctrl of controllers) {
|
|
const inv = ctrl.catalogItemCwId
|
|
? inventoryMap.get(ctrl.catalogItemCwId)
|
|
: undefined;
|
|
if (inv) ctrl.applyInventoryData(inv);
|
|
}
|
|
}
|
|
|
|
return controllers;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Opportunity Activity / Workflow Methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Set Internal Review
|
|
*
|
|
* The quote is ready to be reviewed before it is ready to be sent.
|
|
*/
|
|
public async setInternalReview(): Promise<void> {
|
|
// TODO: implement
|
|
}
|
|
|
|
/**
|
|
* Set Internal Approved
|
|
*
|
|
* The quote has been approved and is ready to be sent out.
|
|
*/
|
|
public async setInternalApproved(): Promise<void> {
|
|
// TODO: implement
|
|
}
|
|
|
|
/**
|
|
* Set Quote Sent
|
|
*
|
|
* The quote has been sent to the customer.
|
|
*/
|
|
public async setQuoteSent(): Promise<void> {
|
|
// TODO: implement
|
|
}
|
|
|
|
/**
|
|
* Set Quote Confirmed
|
|
*
|
|
* The quote has been received by the customer.
|
|
*/
|
|
public async setQuoteConfirmed(): Promise<void> {
|
|
// TODO: implement
|
|
}
|
|
|
|
/**
|
|
* Set Revision Needed
|
|
*
|
|
* The quote needs to be revised and is set to stage revision.
|
|
*/
|
|
public async setRevisionNeeded(): Promise<void> {
|
|
// TODO: implement
|
|
}
|
|
|
|
/**
|
|
* Set Finalized
|
|
*
|
|
* Locks any non-admins from modifying the quote, indicating
|
|
* this is the final iteration of the quote.
|
|
*/
|
|
public async setFinalized(): Promise<void> {
|
|
// TODO: implement
|
|
}
|
|
|
|
/**
|
|
* Convert
|
|
*
|
|
* Converts the quote to a ticket and updates all necessary fields.
|
|
*/
|
|
public async convert(): Promise<void> {
|
|
// TODO: implement
|
|
}
|
|
|
|
/**
|
|
* Add Time
|
|
*
|
|
* Adds time to an activity on this opportunity.
|
|
*
|
|
* @param activityId - The CW activity ID to add time to
|
|
* @param user - The user identifier adding time
|
|
*/
|
|
public async addTime(activityId: number, user: string): Promise<void> {
|
|
// TODO: implement
|
|
}
|
|
|
|
/**
|
|
* Update Product
|
|
*
|
|
* Updates an existing product/line item on this opportunity via PATCH.
|
|
*
|
|
* @param forecastItemId - The CW forecast item ID to update
|
|
* @param data - Key/value pairs to patch
|
|
*/
|
|
public async updateProduct(
|
|
forecastItemId: number,
|
|
data: Record<string, unknown>,
|
|
): Promise<ForecastProductController> {
|
|
try {
|
|
const updated = await opportunityCw.updateProduct(
|
|
this.cwOpportunityId,
|
|
forecastItemId,
|
|
data,
|
|
);
|
|
await invalidateProductsCache(this.cwOpportunityId);
|
|
return new ForecastProductController(updated);
|
|
} catch (err: any) {
|
|
console.error(
|
|
`[updateProduct] Failed to patch forecast item ${forecastItemId} on opportunity ${this.cwOpportunityId}`,
|
|
JSON.stringify(
|
|
{
|
|
data,
|
|
status: err?.response?.status,
|
|
statusText: err?.response?.statusText,
|
|
responseData: err?.response?.data,
|
|
message: err?.message,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resequence Products
|
|
*
|
|
* Stores the desired display order of forecast item IDs locally in
|
|
* the database. No CW API calls are made — CW item IDs are stable
|
|
* and ordering is applied when `fetchProducts()` is called.
|
|
*
|
|
* @param orderedIds - Forecast item IDs in the desired display order
|
|
*/
|
|
public async resequenceProducts(
|
|
orderedIds: number[],
|
|
): Promise<ForecastProductController[]> {
|
|
// Validate all IDs exist in CW
|
|
const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId);
|
|
const existingIds = new Set(
|
|
(forecast.forecastItems ?? []).map((fi) => fi.id),
|
|
);
|
|
for (const id of orderedIds) {
|
|
if (!existingIds.has(id)) {
|
|
throw new GenericError({
|
|
status: 404,
|
|
name: "ForecastItemNotFound",
|
|
message: `Forecast item ${id} not found on opportunity ${this.cwOpportunityId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Persist the sequence locally
|
|
await prisma.opportunity.update({
|
|
where: { id: this.id },
|
|
data: { productSequence: orderedIds },
|
|
});
|
|
this.productSequence = orderedIds;
|
|
|
|
// Invalidate cached products since ordering changed
|
|
await invalidateProductsCache(this.cwOpportunityId);
|
|
|
|
// Return items in the new order
|
|
return this.fetchProducts();
|
|
}
|
|
|
|
/**
|
|
* Append Product Sequence IDs
|
|
*
|
|
* Adds newly created forecast item IDs to the end of the local
|
|
* productSequence array, preserving existing order and avoiding duplicates.
|
|
*/
|
|
private async appendProductSequenceIds(ids: number[]): Promise<void> {
|
|
const normalizedIds = ids.filter(
|
|
(id): id is number => Number.isInteger(id) && id > 0,
|
|
);
|
|
if (normalizedIds.length === 0) return;
|
|
|
|
const current = await prisma.opportunity.findUnique({
|
|
where: { id: this.id },
|
|
select: { productSequence: true },
|
|
});
|
|
|
|
const existing = current?.productSequence ?? [];
|
|
const existingSet = new Set(existing);
|
|
const idsToAppend = normalizedIds.filter((id) => !existingSet.has(id));
|
|
if (idsToAppend.length === 0) {
|
|
this.productSequence = existing;
|
|
return;
|
|
}
|
|
|
|
const updatedSequence = [...existing, ...idsToAppend];
|
|
|
|
await prisma.opportunity.update({
|
|
where: { id: this.id },
|
|
data: { productSequence: updatedSequence },
|
|
});
|
|
|
|
this.productSequence = updatedSequence;
|
|
}
|
|
|
|
/**
|
|
* Add Products
|
|
*
|
|
* Adds one or more products/line items to this opportunity via the
|
|
* ConnectWise forecast endpoint. The caller passes only the fields
|
|
* the user is permitted to set (already filtered by field-level
|
|
* permission gating in the route handler).
|
|
*
|
|
* Accepts a single item or an array of items.
|
|
*/
|
|
public async addProducts(
|
|
data: CWForecastItemCreate | CWForecastItemCreate[],
|
|
): Promise<ForecastProductController[]> {
|
|
try {
|
|
const created = await opportunityCw.createProducts(
|
|
this.cwOpportunityId,
|
|
data,
|
|
);
|
|
await this.appendProductSequenceIds(created.map((item) => item.id));
|
|
await invalidateProductsCache(this.cwOpportunityId);
|
|
return created.map((item) => new ForecastProductController(item));
|
|
} catch (err: any) {
|
|
console.error(
|
|
`[addProducts] Failed to create forecast item(s) on opportunity ${this.cwOpportunityId}`,
|
|
JSON.stringify(
|
|
{
|
|
data,
|
|
status: err?.response?.status,
|
|
statusText: err?.response?.statusText,
|
|
responseData: err?.response?.data,
|
|
message: err?.message,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
throw new GenericError({
|
|
status: err?.response?.status ?? 500,
|
|
name: "AddProductFailed",
|
|
message:
|
|
err?.response?.data?.message ??
|
|
"Failed to add product(s) to opportunity",
|
|
cause: err?.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add Procurement Products
|
|
*
|
|
* Creates one or more procurement products linked to this opportunity.
|
|
* Use this when payloads include procurement-only fields such as customFields.
|
|
*/
|
|
public async addProcurementProducts(
|
|
data: CWProcurementProductCreate | CWProcurementProductCreate[],
|
|
): Promise<CWProcurementProduct[]> {
|
|
try {
|
|
const items = Array.isArray(data) ? data : [data];
|
|
const normalized = items.map((item) => ({
|
|
...item,
|
|
opportunity: { id: this.cwOpportunityId },
|
|
}));
|
|
|
|
const created = await opportunityCw.createProcurementProducts(normalized);
|
|
await this.appendProductSequenceIds(
|
|
created
|
|
.map((item) => item.forecastDetailId)
|
|
.filter((id): id is number => typeof id === "number"),
|
|
);
|
|
await invalidateProductsCache(this.cwOpportunityId);
|
|
return created;
|
|
} catch (err: any) {
|
|
console.error(
|
|
`[addProcurementProducts] Failed to create procurement product(s) on opportunity ${this.cwOpportunityId}`,
|
|
JSON.stringify(
|
|
{
|
|
data,
|
|
status: err?.response?.status,
|
|
statusText: err?.response?.statusText,
|
|
responseData: err?.response?.data,
|
|
message: err?.message,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
throw new GenericError({
|
|
status: err?.response?.status ?? 500,
|
|
name: "AddProcurementProductFailed",
|
|
message:
|
|
err?.response?.data?.message ??
|
|
"Failed to add procurement product(s) to opportunity",
|
|
cause: err?.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete Product
|
|
*
|
|
* Removes a forecast item from this opportunity in ConnectWise,
|
|
* removes the item ID from the local productSequence, and
|
|
* invalidates the products cache.
|
|
*
|
|
* @param forecastItemId - The CW forecast item ID to delete
|
|
*/
|
|
public async deleteProduct(forecastItemId: number): Promise<void> {
|
|
await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId);
|
|
|
|
// Remove the deleted item from the local product sequence
|
|
if (this.productSequence.includes(forecastItemId)) {
|
|
const updatedSequence = this.productSequence.filter(
|
|
(id) => id !== forecastItemId,
|
|
);
|
|
|
|
await prisma.opportunity.update({
|
|
where: { id: this.id },
|
|
data: { productSequence: updatedSequence },
|
|
});
|
|
|
|
this.productSequence = updatedSequence;
|
|
}
|
|
|
|
await invalidateProductsCache(this.cwOpportunityId);
|
|
}
|
|
|
|
/**
|
|
* Fetch Procurement Product By Forecast Item
|
|
*
|
|
* Returns the linked procurement product for a forecast item ID,
|
|
* or null when no procurement record exists.
|
|
*/
|
|
public async fetchProcurementProductByForecastItem(
|
|
forecastItemId: number,
|
|
): Promise<CWProcurementProduct | null> {
|
|
return opportunityCw.fetchProcurementProductByForecastDetail(
|
|
this.cwOpportunityId,
|
|
forecastItemId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update Procurement Product By Forecast Item
|
|
*
|
|
* Finds the linked procurement product for a forecast item and updates it.
|
|
* Returns null when no linked procurement product exists.
|
|
*/
|
|
public async updateProcurementProductByForecastItem(
|
|
forecastItemId: number,
|
|
data: Record<string, unknown>,
|
|
): Promise<CWProcurementProduct | null> {
|
|
const linked =
|
|
await this.fetchProcurementProductByForecastItem(forecastItemId);
|
|
if (!linked?.id) return null;
|
|
|
|
const updated = await opportunityCw.updateProcurementProduct(
|
|
linked.id,
|
|
data,
|
|
);
|
|
await invalidateProductsCache(this.cwOpportunityId);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Set Product Cancellation
|
|
*
|
|
* Updates cancellation fields on the procurement product linked to a
|
|
* forecast item. A quantity of 0 is treated as uncancelled.
|
|
*/
|
|
public async setProductCancellation(
|
|
forecastItemId: number,
|
|
opts: { quantityCancelled: number; cancellationReason?: string | null },
|
|
): Promise<CWProcurementProduct> {
|
|
const linked =
|
|
await this.fetchProcurementProductByForecastItem(forecastItemId);
|
|
|
|
if (!linked?.id) {
|
|
throw new GenericError({
|
|
status: 404,
|
|
name: "ProcurementProductNotFound",
|
|
message:
|
|
"No linked procurement product found for the specified forecast item",
|
|
});
|
|
}
|
|
|
|
const quantityCancelled = Math.max(0, Math.trunc(opts.quantityCancelled));
|
|
const cancelledFlag = quantityCancelled > 0;
|
|
|
|
const updated = await this.updateProcurementProductByForecastItem(
|
|
forecastItemId,
|
|
{
|
|
quantityCancelled,
|
|
cancelledFlag,
|
|
cancelledReason: cancelledFlag
|
|
? (opts.cancellationReason ?? null)
|
|
: null,
|
|
},
|
|
);
|
|
|
|
if (!updated) {
|
|
throw new GenericError({
|
|
status: 404,
|
|
name: "ProcurementProductNotFound",
|
|
message:
|
|
"No linked procurement product found for the specified forecast item",
|
|
});
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Add Note
|
|
*
|
|
* Creates a new note on this opportunity in ConnectWise.
|
|
*
|
|
* @param note - The note text to add
|
|
* @param user - The user identifier adding the note
|
|
* @param opts - Optional flags
|
|
*/
|
|
public async addNote(
|
|
note: string,
|
|
user: string,
|
|
opts?: { flagged?: boolean },
|
|
): Promise<CWOpportunityNote> {
|
|
const created = await opportunityCw.createNote(this.cwOpportunityId, {
|
|
text: note,
|
|
flagged: opts?.flagged ?? false,
|
|
});
|
|
await invalidateNotesCache(this.cwOpportunityId);
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* Update Note
|
|
*
|
|
* Updates an existing note on this opportunity in ConnectWise.
|
|
*
|
|
* @param noteId - The CW note ID to update
|
|
* @param data - The fields to update
|
|
*/
|
|
public async updateNote(
|
|
noteId: number,
|
|
data: { text?: string; flagged?: boolean },
|
|
): Promise<CWOpportunityNote> {
|
|
const updated = await opportunityCw.updateNote(
|
|
this.cwOpportunityId,
|
|
noteId,
|
|
data,
|
|
);
|
|
await invalidateNotesCache(this.cwOpportunityId);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Delete Note
|
|
*
|
|
* Deletes a note from this opportunity in ConnectWise.
|
|
*
|
|
* @param noteId - The CW note ID to delete
|
|
*/
|
|
public async deleteNote(noteId: number): Promise<void> {
|
|
await opportunityCw.deleteNote(this.cwOpportunityId, noteId);
|
|
await invalidateNotesCache(this.cwOpportunityId);
|
|
}
|
|
|
|
/**
|
|
* To JSON
|
|
*
|
|
* Serializes the opportunity into a safe, API-friendly object.
|
|
*/
|
|
public toJson(): Record<string, any> {
|
|
return {
|
|
id: this.id,
|
|
cwOpportunityId: this.cwOpportunityId,
|
|
name: this.name,
|
|
description: this.notes,
|
|
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
|
stage: this.stageCwId
|
|
? { id: this.stageCwId, name: this.stageName }
|
|
: null,
|
|
status: this.statusCwId
|
|
? { id: this.statusCwId, name: this.statusName }
|
|
: null,
|
|
priority: this.priorityCwId
|
|
? { id: this.priorityCwId, name: this.priorityName }
|
|
: null,
|
|
rating: this.ratingCwId
|
|
? { id: this.ratingCwId, name: this.ratingName }
|
|
: null,
|
|
source: this.source,
|
|
campaign: this.campaignCwId
|
|
? { id: this.campaignCwId, name: this.campaignName }
|
|
: null,
|
|
primarySalesRep: this.primarySalesRepCwId
|
|
? {
|
|
id: this.primarySalesRepCwId,
|
|
identifier: this.primarySalesRepIdentifier,
|
|
name: this.primarySalesRepName,
|
|
}
|
|
: null,
|
|
secondarySalesRep: this.secondarySalesRepCwId
|
|
? {
|
|
id: this.secondarySalesRepCwId,
|
|
identifier: this.secondarySalesRepIdentifier,
|
|
name: this.secondarySalesRepName,
|
|
}
|
|
: null,
|
|
company: this._company
|
|
? this._company.toJson({
|
|
includeAllContacts: true,
|
|
includeAddress: true,
|
|
includePrimaryContact: false,
|
|
})
|
|
: this.companyCwId
|
|
? { id: this.companyCwId, name: this.companyName }
|
|
: null,
|
|
contact: this.contactCwId
|
|
? { id: this.contactCwId, name: this.contactName }
|
|
: null,
|
|
site: this._siteData
|
|
? this._siteData
|
|
: this.siteCwId
|
|
? { id: this.siteCwId, name: this.siteName }
|
|
: null,
|
|
customerPO: this.customerPO,
|
|
totalSalesTax: this.totalSalesTax,
|
|
probability: this.probability,
|
|
location: this.locationCwId
|
|
? { id: this.locationCwId, name: this.locationName }
|
|
: null,
|
|
department: this.departmentCwId
|
|
? { id: this.departmentCwId, name: this.departmentName }
|
|
: null,
|
|
expectedCloseDate: this.expectedCloseDate,
|
|
pipelineChangeDate: this.pipelineChangeDate,
|
|
dateBecameLead: this.dateBecameLead,
|
|
closedDate: this.closedDate,
|
|
closedFlag: this.closedFlag,
|
|
closedBy: this.closedByCwId
|
|
? { id: this.closedByCwId, name: this.closedByName }
|
|
: null,
|
|
companyId: this.companyId,
|
|
cwLastUpdated: this.cwLastUpdated,
|
|
productSequence: this.productSequence,
|
|
createdAt: this.createdAt,
|
|
updatedAt: this.updatedAt,
|
|
customFields: this._customFields ?? [],
|
|
activities: this._activities?.map((a) => a.toJson()) ?? [],
|
|
};
|
|
}
|
|
}
|