d7b374f8ab
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
232 lines
6.8 KiB
TypeScript
232 lines
6.8 KiB
TypeScript
import { CatalogItem } from "../../generated/prisma/client";
|
|
import { prisma } from "../constants";
|
|
import { catalogCw } from "../modules/cw-utils/procurement/catalog";
|
|
import { CatalogItem as CWCatalogItem } from "../modules/cw-utils/procurement/catalog.types";
|
|
import GenericError from "../Errors/GenericError";
|
|
|
|
/**
|
|
* Catalog Item Controller
|
|
*
|
|
* This class encapsulates a catalog item entity and provides domain methods
|
|
* for accessing, refreshing, and serializing catalog item data. It bridges
|
|
* the internal database representation with ConnectWise catalog data.
|
|
*/
|
|
export class CatalogItemController {
|
|
public readonly id: string;
|
|
public name: string;
|
|
public description: string | null;
|
|
public customerDescription: string | null;
|
|
public internalNotes: string | null;
|
|
|
|
public readonly cwCatalogId: number;
|
|
public readonly identifier: string | null;
|
|
|
|
public category: string | null;
|
|
public categoryCwId: number | null;
|
|
public subcategory: string | null;
|
|
public subcategoryCwId: number | null;
|
|
|
|
public manufacturer: string | null;
|
|
public manufactureCwId: number | null;
|
|
public partNumber: string | null;
|
|
|
|
public vendorName: string | null;
|
|
public vendorSku: string | null;
|
|
public vendorCwId: number | null;
|
|
|
|
public price: number;
|
|
public cost: number;
|
|
|
|
public inactive: boolean;
|
|
public salesTaxable: boolean;
|
|
|
|
public onHand: number;
|
|
public cwLastUpdated: Date | null;
|
|
|
|
private _linkedItems: CatalogItemController[];
|
|
|
|
public readonly createdAt: Date;
|
|
public updatedAt: Date;
|
|
|
|
constructor(
|
|
itemData: CatalogItem & {
|
|
linkedItems?: CatalogItem[];
|
|
},
|
|
) {
|
|
this.id = itemData.id;
|
|
this.name = itemData.name;
|
|
this.description = itemData.description;
|
|
this.customerDescription = itemData.customerDescription;
|
|
this.internalNotes = itemData.internalNotes;
|
|
this.cwCatalogId = itemData.cwCatalogId;
|
|
this.identifier = itemData.identifier;
|
|
this.category = itemData.category;
|
|
this.categoryCwId = itemData.categoryCwId;
|
|
this.subcategory = itemData.subcategory;
|
|
this.subcategoryCwId = itemData.subcategoryCwId;
|
|
this.manufacturer = itemData.manufacturer;
|
|
this.manufactureCwId = itemData.manufactureCwId;
|
|
this.partNumber = itemData.partNumber;
|
|
this.vendorName = itemData.vendorName;
|
|
this.vendorSku = itemData.vendorSku;
|
|
this.vendorCwId = itemData.vendorCwId;
|
|
this.price = itemData.price;
|
|
this.cost = itemData.cost;
|
|
this.inactive = itemData.inactive;
|
|
this.salesTaxable = itemData.salesTaxable;
|
|
this.onHand = itemData.onHand;
|
|
this.cwLastUpdated = itemData.cwLastUpdated;
|
|
this.createdAt = itemData.createdAt;
|
|
this.updatedAt = itemData.updatedAt;
|
|
|
|
this._linkedItems = (itemData.linkedItems ?? []).map(
|
|
(linked) => new CatalogItemController(linked),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Refresh Inventory
|
|
*
|
|
* Fetches the latest on-hand inventory count from ConnectWise
|
|
* and updates both the controller state and the database.
|
|
*
|
|
* @returns {Promise<CatalogItemController>} - The updated controller
|
|
*/
|
|
public async refreshInventory(): Promise<CatalogItemController> {
|
|
const onHand = await catalogCw.fetchInventoryOnHand(this.cwCatalogId);
|
|
|
|
if (onHand !== this.onHand) {
|
|
await prisma.catalogItem.update({
|
|
where: { id: this.id },
|
|
data: { onHand },
|
|
});
|
|
this.onHand = onHand;
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Fetch Linked Items
|
|
*
|
|
* Returns the linked catalog items as an array of controllers.
|
|
*
|
|
* @returns {CatalogItemController[]} - Array of linked item controllers
|
|
*/
|
|
public getLinkedItems(): CatalogItemController[] {
|
|
return this._linkedItems;
|
|
}
|
|
|
|
/**
|
|
* Link Item
|
|
*
|
|
* Links another catalog item to this item. The relationship is bidirectional
|
|
* via the Prisma implicit many-to-many.
|
|
*
|
|
* @param targetId - The internal ID of the catalog item to link
|
|
* @returns {Promise<CatalogItemController>} - The updated controller
|
|
*/
|
|
public async linkItem(targetId: string): Promise<CatalogItemController> {
|
|
if (targetId === this.id) {
|
|
throw new GenericError({
|
|
message: "Cannot link a catalog item to itself",
|
|
name: "InvalidLinkTarget",
|
|
cause: `Item '${this.id}' cannot be linked to itself`,
|
|
status: 400,
|
|
});
|
|
}
|
|
|
|
const target = await prisma.catalogItem.findFirst({
|
|
where: { id: targetId },
|
|
});
|
|
|
|
if (!target) {
|
|
throw new GenericError({
|
|
message: "Target catalog item not found",
|
|
name: "CatalogItemNotFound",
|
|
cause: `No catalog item exists with ID '${targetId}'`,
|
|
status: 404,
|
|
});
|
|
}
|
|
|
|
const updated = await prisma.catalogItem.update({
|
|
where: { id: this.id },
|
|
data: {
|
|
linkedItems: { connect: { id: targetId } },
|
|
},
|
|
include: { linkedItems: true },
|
|
});
|
|
|
|
this._linkedItems = (updated.linkedItems ?? []).map(
|
|
(linked) => new CatalogItemController(linked),
|
|
);
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Unlink Item
|
|
*
|
|
* Removes the link between this catalog item and another.
|
|
*
|
|
* @param targetId - The internal ID of the catalog item to unlink
|
|
* @returns {Promise<CatalogItemController>} - The updated controller
|
|
*/
|
|
public async unlinkItem(targetId: string): Promise<CatalogItemController> {
|
|
const updated = await prisma.catalogItem.update({
|
|
where: { id: this.id },
|
|
data: {
|
|
linkedItems: { disconnect: { id: targetId } },
|
|
},
|
|
include: { linkedItems: true },
|
|
});
|
|
|
|
this._linkedItems = (updated.linkedItems ?? []).map(
|
|
(linked) => new CatalogItemController(linked),
|
|
);
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* To JSON
|
|
*
|
|
* Serializes the catalog item into a safe, API-friendly object.
|
|
*
|
|
* @param opts - Options to control output
|
|
* @returns - A JSON-safe representation of the catalog item
|
|
*/
|
|
public toJson(opts?: { includeLinkedItems?: boolean }): Record<string, any> {
|
|
return {
|
|
id: this.id,
|
|
cwCatalogId: this.cwCatalogId,
|
|
identifier: this.identifier,
|
|
name: this.name,
|
|
description: this.description,
|
|
customerDescription: this.customerDescription,
|
|
internalNotes: this.internalNotes,
|
|
category: this.category,
|
|
categoryCwId: this.categoryCwId,
|
|
subcategory: this.subcategory,
|
|
subcategoryCwId: this.subcategoryCwId,
|
|
manufacturer: this.manufacturer,
|
|
manufactureCwId: this.manufactureCwId,
|
|
partNumber: this.partNumber,
|
|
vendorName: this.vendorName,
|
|
vendorSku: this.vendorSku,
|
|
vendorCwId: this.vendorCwId,
|
|
price: this.price,
|
|
cost: this.cost,
|
|
inactive: this.inactive,
|
|
salesTaxable: this.salesTaxable,
|
|
onHand: this.onHand,
|
|
cwLastUpdated: this.cwLastUpdated,
|
|
linkedItems: opts?.includeLinkedItems
|
|
? this._linkedItems.map((item) => item.toJson())
|
|
: undefined,
|
|
createdAt: this.createdAt,
|
|
updatedAt: this.updatedAt,
|
|
};
|
|
}
|
|
}
|