287 lines
8.6 KiB
TypeScript
287 lines
8.6 KiB
TypeScript
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<string, any> {
|
|
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,
|
|
};
|
|
}
|
|
}
|