feat: sales activities, forecast products, catalog categories, member cache, procurement filters, and comprehensive tests
New features: - ActivityController and manager for CW sales activities (CRUD) - ForecastProductController for opportunity forecast/product lines - CW member cache with dual-layer (in-memory + Redis) resolution - Catalog category/subcategory/ecosystem taxonomy module - Quote statuses type definitions with CW mapping - User-defined fields (UDF) module with cache and event refresh - Company sites CW module with serialization - Procurement manager filters (category, ecosystem, manufacturer, price, stock) - Opportunity notes CRUD and product line management via CW API - Opportunity type definitions endpoint Updates: - OpportunityController: CW refresh, company hydration, activities, custom fields - UserController: cwIdentifier field for CW member linking - CatalogItemController: category/subcategory fields from CW - PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions - API routes: procurement categories/filters, sales notes/products, opportunity types - Global events: UDF and member refresh intervals on startup Tests (414 passing): - ActivityController, ForecastProductController, OpportunityController unit tests - UserController cwIdentifier tests - catalogCategories, companySites, memberCache, procurement module tests - activityTypes, opportunityTypes, quoteStatuses type tests - permissionNodes subCategories and getAllPermissionNodes tests - Updated test setup with redis mock, API method mocks, and builder helpers
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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 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.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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
revenue: this.revenue,
|
||||
cost: this.cost,
|
||||
margin: this.margin,
|
||||
profit: this.profit,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user