import { CWForecastItem } from "../modules/cw-utils/opportunities/opportunity.types"; /** * Forecast Product Controller * * Domain model class that encapsulates a ConnectWise Forecast Item (product/ * revenue line item on an opportunity). Forecast products are not persisted * locally — all data is sourced directly from the ConnectWise API. */ export class ForecastProductController { public readonly cwForecastId: number; public forecastDescription: string; public opportunityCwId: number | null; public opportunityName: string | null; public quantity: number; public statusCwId: number | null; public statusName: string | null; public catalogItemCwId: number | null; public catalogItemIdentifier: string | null; public productDescription: string; public customerDescription: string | null; public productNarrative: string | null; public productClass: string; public forecastType: string; public revenue: number; public cost: number; public margin: number; public percentage: number; public includeFlag: boolean; public linkFlag: boolean; public recurringFlag: boolean; public taxableFlag: boolean; public recurringRevenue: number; public recurringCost: number; public cycles: number; public sequenceNumber: number; public subNumber: number; public quoteWerksQuantity: number; public cwLastUpdated: Date | null; public cwUpdatedBy: string | null; // Cancellation data (from procurement products endpoint) public cancelledFlag: boolean; public quantityCancelled: number; public cancelledReason: string | null; public cancelledBy: number | null; public cancelledDate: Date | null; // Internal inventory data (from local CatalogItem database) public onHand: number | null; public inStock: boolean | null; constructor(data: CWForecastItem) { this.cwForecastId = data.id; this.forecastDescription = data.forecastDescription; this.opportunityCwId = data.opportunity?.id ?? null; this.opportunityName = data.opportunity?.name ?? null; this.quantity = data.quantity; this.statusCwId = data.status?.id ?? null; this.statusName = data.status?.name ?? null; this.catalogItemCwId = data.catalogItem?.id ?? null; this.catalogItemIdentifier = data.catalogItem?.identifier ?? null; this.productDescription = data.productDescription; this.customerDescription = data.customerDescription ?? null; this.productNarrative = data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null; this.productClass = data.productClass; this.forecastType = data.forecastType; this.revenue = data.revenue; this.cost = data.cost; this.margin = data.margin; this.percentage = data.percentage; this.includeFlag = data.includeFlag ?? false; this.linkFlag = data.linkFlag ?? false; this.recurringFlag = data.recurringFlag ?? false; this.taxableFlag = data.taxableFlag ?? false; this.recurringRevenue = data.recurringRevenue ?? 0; this.recurringCost = data.recurringCost ?? 0; this.cycles = data.cycles ?? 0; this.sequenceNumber = data.sequenceNumber ?? 0; this.subNumber = data.subNumber ?? 0; this.quoteWerksQuantity = data.quoteWerksQuantity ?? 0; this.cwLastUpdated = data._info?.lastUpdated ? new Date(data._info.lastUpdated) : null; this.cwUpdatedBy = data._info?.updatedBy ?? null; // Cancellation defaults — enriched later via applyCancellationData() this.cancelledFlag = false; this.quantityCancelled = 0; this.cancelledReason = null; this.cancelledBy = null; this.cancelledDate = null; // Inventory defaults — enriched later via applyInventoryData() this.onHand = null; this.inStock = null; } /** * Apply Cancellation Data * * Enriches this forecast product with cancellation data from the * procurement products endpoint. */ /** * Apply Procurement Custom Fields * * Enriches this forecast product with custom field data from the * procurement products endpoint (the forecast endpoint does not * return customFields). */ public applyProcurementCustomFields(data: { customFields?: Array<{ id: number; value?: unknown }>; }): void { const narrative = data.customFields ?.find((f) => f.id === 46) ?.value?.toString(); if (narrative) { this.productNarrative = narrative; } } public applyCancellationData(data: { cancelledFlag?: boolean; quantityCancelled?: number; cancelledReason?: string; cancelledBy?: number; cancelledDate?: string; }): void { this.cancelledFlag = data.cancelledFlag ?? false; this.quantityCancelled = data.quantityCancelled ?? 0; this.cancelledReason = data.cancelledReason ?? null; this.cancelledBy = data.cancelledBy ?? null; this.cancelledDate = data.cancelledDate ? new Date(data.cancelledDate) : null; } /** * Apply Inventory Data * * Enriches this forecast product with internal inventory data from * the local CatalogItem database. */ public applyInventoryData(data: { onHand: number }): void { this.onHand = data.onHand; this.inStock = data.onHand > 0; } /** * Profit * * Returns the calculated profit (revenue - cost). */ public get profit(): number { return this.revenue - this.cost; } /** * Effective Quantity * * Returns the quantity adjusted for cancellations (minimum 0). */ public get effectiveQuantity(): number { if (this.cancellationType === "full") return 0; return Math.max(0, this.quantity - this.quantityCancelled); } /** * Effective Revenue * * Returns the revenue adjusted proportionally for cancelled units. */ public get effectiveRevenue(): number { if (this.cancellationType === "full" || this.quantity <= 0) return 0; const unitPrice = this.revenue / this.quantity; return unitPrice * this.effectiveQuantity; } /** * Effective Cost * * Returns the cost adjusted proportionally for cancelled units. */ public get effectiveCost(): number { if (this.cancellationType === "full" || this.quantity <= 0) return 0; const unitCost = this.cost / this.quantity; return unitCost * this.effectiveQuantity; } /** * Cancelled * * Returns true if the forecast item has been cancelled (fully or partially). */ public get cancelled(): boolean { return this.cancelledFlag; } /** * Cancellation Type * * Returns the type of cancellation: * - `"full"` — all units have been cancelled (`quantityCancelled >= quantity`) * - `"partial"` — some units cancelled but not all * - `null` — not cancelled */ public get cancellationType(): "full" | "partial" | null { if (!this.cancelledFlag || this.quantityCancelled <= 0) return null; return this.quantityCancelled >= this.quantity ? "full" : "partial"; } /** * To JSON * * Serializes the forecast product into a safe, API-friendly object. */ public toJson(): Record { return { id: this.cwForecastId, forecastDescription: this.forecastDescription, opportunity: this.opportunityCwId ? { id: this.opportunityCwId, name: this.opportunityName } : null, quantity: this.quantity, status: this.statusCwId ? { id: this.statusCwId, name: this.statusName } : null, cancelled: this.cancelled, cancellationType: this.cancellationType, quantityCancelled: this.quantityCancelled, cancelledReason: this.cancelledReason, cancelledDate: this.cancelledDate, catalogItem: this.catalogItemCwId ? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier } : null, productDescription: this.productDescription, customerDescription: this.customerDescription, productNarrative: this.productNarrative, productClass: this.productClass, forecastType: this.forecastType, revenue: this.revenue, cost: this.cost, margin: this.margin, profit: this.profit, effectiveQuantity: this.effectiveQuantity, effectiveRevenue: this.effectiveRevenue, effectiveCost: this.effectiveCost, percentage: this.percentage, includeFlag: this.includeFlag, linkFlag: this.linkFlag, recurringFlag: this.recurringFlag, taxableFlag: this.taxableFlag, recurringRevenue: this.recurringRevenue, recurringCost: this.recurringCost, cycles: this.cycles, sequenceNumber: this.sequenceNumber, subNumber: this.subNumber, cwLastUpdated: this.cwLastUpdated, cwUpdatedBy: this.cwUpdatedBy, onHand: this.onHand, inStock: this.inStock, }; } }