This commit is contained in:
2026-02-24 17:53:43 -06:00
parent da6e0311d8
commit 06e021f8a1
21 changed files with 3144 additions and 77 deletions
@@ -0,0 +1,75 @@
import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import { CatalogItem } from "./catalog.types.ts";
export interface CatalogSummary {
id: number;
_info?: Record<string, string>;
}
export interface InventoryEntry {
id: number;
onHand: number;
}
export const catalogCw = {
countItems: async (): Promise<number> => {
const response = await connectWiseApi.get("/procurement/catalog/count");
return response.data.count;
},
fetchAllCatalogSummary: async (): Promise<
Collection<number, CatalogSummary>
> => {
const allItems = new Collection<number, CatalogSummary>();
const pageSize = 1000;
const count = await catalogCw.countItems();
const totalPages = Math.ceil(count / pageSize);
for (let page = 0; page < totalPages; page++) {
const response = await connectWiseApi.get(
`/procurement/catalog?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
);
const items: CatalogSummary[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
}
return allItems;
},
fetchInventoryOnHand: async (cwCatalogId: number): Promise<number> => {
const response = await connectWiseApi.get(
`/procurement/catalog/${cwCatalogId}/inventory?fields=onHand`,
);
const entries: InventoryEntry[] = response.data;
return entries.reduce((sum, e) => sum + (e.onHand || 0), 0);
},
fetchAllItemsFromCw: async (): Promise<Collection<number, CatalogItem>> => {
const allItems = new Collection<number, CatalogItem>();
const pageSize = 1000;
const count = await catalogCw.countItems();
const totalPages = Math.ceil(count / pageSize);
for (let page = 0; page < totalPages; page++) {
const response = await connectWiseApi.get(
`/procurement/catalog?page=${page + 1}&pageSize=${pageSize}`,
);
const items: CatalogItem[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
}
return allItems;
},
fetch: async (id: string): Promise<CatalogItem> => {
const response = await connectWiseApi.get(
`/procurement/catalog/items/${id}`,
);
return response.data;
},
};
@@ -0,0 +1,75 @@
interface CWReference {
id: number;
name: string;
_info?: Record<string, string>;
}
interface CWVendorReference {
id: number;
identifier: string;
name: string;
_info?: Record<string, string>;
}
interface CWCustomField {
id: number;
caption: string;
type: string;
entryMethod: string;
numberOfDecimals: number;
value: unknown;
connectWiseId: string;
rowNum: number;
userDefinedFieldRecId: number;
podId: string;
}
export interface CatalogItem {
id: number;
identifier: string;
description: string;
inactiveFlag: boolean;
subcategory: CWReference;
type: CWReference;
productClass: string;
serializedFlag: boolean;
serializedCostFlag: boolean;
phaseProductFlag: boolean;
unitOfMeasure: CWReference;
minStockLevel: number;
price: number;
cost: number;
priceAttribute: string;
taxableFlag: boolean;
dropShipFlag: boolean;
specialOrderFlag: boolean;
customerDescription: string;
manufacturer: CWReference;
manufacturerPartNumber: string;
vendor: CWVendorReference;
vendorSku: string;
notes: string;
integrationXRef: string;
sla: CWReference;
entityType: CWReference;
recurringFlag: boolean;
recurringRevenue: number;
recurringCost: number;
recurringOneTimeFlag: boolean;
recurringBillCycle: CWReference;
recurringCycleType: string;
calculatedPriceFlag: boolean;
calculatedCostFlag: boolean;
category: CWReference;
calculatedPrice: number;
calculatedCost: number;
billableOption: string;
connectWiseID: string;
agreementType: CWReference;
markupPercentage: number;
markupFlag: boolean;
autoUpdateUnitCostFlag: boolean;
autoUpdateUnitPriceFlag: boolean;
_info?: Record<string, string>;
customFields?: CWCustomField[];
}
@@ -0,0 +1,140 @@
import { prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { catalogCw } from "./catalog";
export const refreshCatalog = async () => {
events.emit("cw:catalog:refresh:check");
// 1. Fetch lightweight summaries from CW (id + _info with lastUpdated)
const cwSummaries = await catalogCw.fetchAllCatalogSummary();
// 2. Fetch all DB items with their cwCatalogId and cwLastUpdated
const dbItems = await prisma.catalogItem.findMany({
select: { cwCatalogId: true, cwLastUpdated: true },
});
const dbMap = new Map(
dbItems.map((item) => [item.cwCatalogId, item.cwLastUpdated]),
);
// 3. Compare CW lastUpdated vs DB cwLastUpdated — collect IDs that are stale or new
const staleIds: number[] = [];
for (const [cwId, summary] of cwSummaries) {
const cwLastUpdated = summary._info?.lastUpdated
? new Date(summary._info.lastUpdated)
: null;
const dbLastUpdated = dbMap.get(cwId) ?? null;
// New item (not in DB) or CW has a newer timestamp
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
staleIds.push(cwId);
}
}
if (staleIds.length === 0) {
events.emit("cw:catalog:refresh:skipped", {
totalCw: cwSummaries.size,
totalDb: dbItems.length,
staleCount: 0,
});
return;
}
events.emit("cw:catalog:refresh:started", {
totalCw: cwSummaries.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
});
// 4. Fetch full catalog data, then filter to only stale items
const staleIdSet = new Set(staleIds);
const allCwItems = await catalogCw.fetchAllItemsFromCw();
const allStaleItems = new Map<number, any>();
for (const [id, item] of allCwItems) {
if (staleIdSet.has(id)) {
allStaleItems.set(id, item);
}
}
// 5. Batch fetch inventory onHand for stale items (50 concurrent)
const onHandMap = new Map<number, number>();
const batchSize = 50;
for (let i = 0; i < staleIds.length; i += batchSize) {
const batch = staleIds.slice(i, i + batchSize);
await Promise.all(
batch.map(async (cwId) => {
try {
const onHand = await catalogCw.fetchInventoryOnHand(cwId);
onHandMap.set(cwId, onHand);
} catch {
onHandMap.set(cwId, 0);
}
}),
);
}
// 6. Upsert only the stale/new items
const updatedCount = (
await Promise.all(
staleIds.map(async (cwId) => {
const item = allStaleItems.get(cwId);
if (!item) return null;
const cwLastUpdated = item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date();
const onHand = onHandMap.get(cwId) ?? 0;
return await prisma.catalogItem.upsert({
where: { cwCatalogId: cwId },
create: {
cwCatalogId: cwId,
name: item.description,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
vendorName: item.vendor?.name,
vendorSku: item.vendorSku,
vendorCwId: item.vendor?.id,
price: item.price,
cost: item.cost,
inactive: item.inactiveFlag,
salesTaxable: item.taxableFlag,
onHand,
cwLastUpdated,
},
update: {
name: item.description,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
vendorName: item.vendor?.name,
vendorSku: item.vendorSku,
vendorCwId: item.vendor?.id,
price: item.price,
cost: item.cost,
inactive: item.inactiveFlag,
salesTaxable: item.taxableFlag,
onHand,
cwLastUpdated,
},
});
}),
)
).filter(Boolean).length;
events.emit("cw:catalog:refresh:completed", {
totalCw: cwSummaries.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
itemsUpdated: updatedCount,
});
};
@@ -0,0 +1,74 @@
import { prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { catalogCw } from "./catalog";
export const refreshInventory = async () => {
events.emit("cw:inventory:refresh:check");
// 1. Get all active catalog items from DB
const dbItems = await prisma.catalogItem.findMany({
where: { inactive: false },
select: { cwCatalogId: true, onHand: true },
});
if (dbItems.length === 0) {
events.emit("cw:inventory:refresh:skipped", {
totalItems: 0,
updatedCount: 0,
});
return;
}
events.emit("cw:inventory:refresh:started", {
totalItems: dbItems.length,
});
// 2. Batch fetch inventory onHand for all items (50 concurrent)
const onHandMap = new Map<number, number>();
const batchSize = 150;
for (let i = 0; i < dbItems.length; i += batchSize) {
const batch = dbItems.slice(i, i + batchSize);
await Promise.all(
batch.map(async (item) => {
try {
const onHand = await catalogCw.fetchInventoryOnHand(item.cwCatalogId);
onHandMap.set(item.cwCatalogId, onHand);
} catch {
onHandMap.set(item.cwCatalogId, item.onHand);
}
}),
);
}
// 3. Only update items where onHand has changed
const updates = dbItems.filter((item) => {
const newOnHand = onHandMap.get(item.cwCatalogId) ?? item.onHand;
return newOnHand !== item.onHand;
});
if (updates.length === 0) {
events.emit("cw:inventory:refresh:skipped", {
totalItems: dbItems.length,
updatedCount: 0,
});
return;
}
const updatedCount = (
await Promise.all(
updates.map(async (item) => {
const newOnHand = onHandMap.get(item.cwCatalogId) ?? item.onHand;
return await prisma.catalogItem.update({
where: { cwCatalogId: item.cwCatalogId },
data: { onHand: newOnHand },
});
}),
)
).length;
events.emit("cw:inventory:refresh:completed", {
totalItems: dbItems.length,
updatedCount,
});
};
+50
View File
@@ -108,6 +108,56 @@ interface EventTypes {
company: CompanyController;
updatedFields: Partial<Company>;
}) => void;
// ConnectWise Catalog Events
"cw:catalog:refresh:check": () => void;
"cw:catalog:refresh:started": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
"cw:catalog:refresh:completed": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
itemsUpdated: number;
}) => void;
"cw:catalog:refresh:skipped": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
// UniFi Events
"unifi:login:ok": (data: {
type: "unifi-os" | "legacy";
status: number;
}) => void;
"unifi:login:fallback": () => void;
"unifi:reauth": () => void;
"unifi:sites:sync:started": () => void;
"unifi:sites:sync:completed": (data: {
total: number;
created: number;
updated: number;
}) => void;
"unifi:wlan:fetched": (data: { path: string }) => void;
"unifi:wlan:fetch_failed": (data: {
path: string;
status: number | unknown;
}) => void;
// ConnectWise Inventory Events
"cw:inventory:refresh:check": () => void;
"cw:inventory:refresh:started": (data: { totalItems: number }) => void;
"cw:inventory:refresh:completed": (data: {
totalItems: number;
updatedCount: number;
}) => void;
"cw:inventory:refresh:skipped": (data: {
totalItems: number;
updatedCount: number;
}) => void;
}
export const events = new Eventra<EventTypes>();
+9 -8
View File
@@ -1,5 +1,6 @@
import axios, { AxiosInstance } from "axios";
import https from "https";
import { events } from "../globalEvents";
import {
ApGroup,
ApRadioWifiUsage,
@@ -57,13 +58,13 @@ export class UnifiClient {
try {
// UniFi OS
const res = await this.client.post("/api/auth/login", body);
console.log("Login OK (UniFi OS)", res.status);
events.emit("unifi:login:ok", { type: "unifi-os", status: res.status });
this.persistSession(res);
} catch (e) {
// Legacy controller
console.log("UniFi OS login failed, trying legacy...");
events.emit("unifi:login:fallback");
const res = await this.client.post("/api/login", body);
console.log("Login OK (legacy)", res.status);
events.emit("unifi:login:ok", { type: "legacy", status: res.status });
this.persistSession(res);
}
}
@@ -78,13 +79,13 @@ export class UnifiClient {
try {
const res = await this.client.get(path);
const data = (res.data?.data ?? res.data) as WlanConfRaw[];
console.log(`Fetched wlan from ${path}`);
events.emit("unifi:wlan:fetched", { path });
return data;
} catch (e) {
console.log(
`Failed ${path}:`,
axios.isAxiosError(e) ? e.response?.status : e,
);
events.emit("unifi:wlan:fetch_failed", {
path,
status: axios.isAxiosError(e) ? e.response?.status : e,
});
}
}