fix: fixed warehouse inventory numbers and some button verbage

This commit is contained in:
2026-04-21 04:38:07 +00:00
parent c94de8198f
commit 5194d0e21e
29 changed files with 835 additions and 575 deletions
@@ -0,0 +1,52 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { procurement } from "../../../managers/procurement";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { prisma } from "../../../constants";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/procurement/items/:identifier/inventory */
export default createRoute(
"get",
["/items/:identifier/inventory"],
async (c) => {
const identifier = c.req.param("identifier");
const includeWarehouse = c.req.query("includeWarehouse") === "true";
const includeWarehouseBin = c.req.query("includeWarehouseBin") === "true";
const item = await procurement.fetchItem(identifier);
const rows = await prisma.productInventory.findMany({
where: { itemId: item.cwCatalogId },
include: {
warehouse: includeWarehouse,
warehouseBin: includeWarehouseBin,
},
orderBy: [{ warehouseId: "asc" }, { warehouseBinId: "asc" }],
});
const data = rows.map((row) => ({
id: row.id,
qtyOnHand: row.qtyOnHand,
warehouseId: row.warehouseId,
warehouseBinId: row.warehouseBinId,
...(includeWarehouse && {
warehouse: (row as any).warehouse
? { id: (row as any).warehouse.id, name: (row as any).warehouse.name }
: null,
}),
...(includeWarehouseBin && {
warehouseBin: (row as any).warehouseBin
? { id: (row as any).warehouseBin.id, name: (row as any).warehouseBin.name }
: null,
}),
}));
const response = apiResponse.successful(
"Product inventory fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.fetch"] }),
);
+2
View File
@@ -1,6 +1,7 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetch } from "./[id]/fetch";
import { default as refreshInventory } from "./[id]/refreshInventory";
import { default as inventoryByWarehouse } from "./[id]/inventoryByWarehouse";
import { default as link } from "./[id]/link";
import { default as unlink } from "./[id]/unlink";
import { default as fetchLinked } from "./[id]/fetchLinked";
@@ -15,6 +16,7 @@ export {
fetchAll,
fetchLinked,
filters,
inventoryByWarehouse,
link,
refreshInventory,
unlink,
+15 -1
View File
@@ -9,12 +9,14 @@ import {
OpportunityStatus,
StatusIdToKey,
} from "../../../../workflows/wf.opportunity";
import { resolveCwProbabilityId } from "../../../../modules/cw-utils/opportunities/cwProbabilityCache";
const updateSchema = z
.object({
name: z.string().min(1).optional(),
notes: z.string().optional(),
interest: z.enum(["HOT", "WARM", "COLD"]).nullable().optional(),
probability: z.number().min(0).max(100).optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
@@ -66,7 +68,19 @@ export default createRoute(
}
try {
const updated = await item.updateOpportunity(data);
const { probability: probabilityPercent, ...rest } = data;
// Resolve numeric probability → CW reference ID
let probabilityRef: { id: number } | undefined;
if (probabilityPercent !== undefined) {
const probId = await resolveCwProbabilityId(probabilityPercent);
if (probId != null) probabilityRef = { id: probId };
}
const updated = await item.updateOpportunity({
...rest,
...(probabilityRef !== undefined ? { probability: probabilityRef } : {}),
});
const response = apiResponse.successful(
"Opportunity updated successfully!",
@@ -7,6 +7,7 @@ import { timeEntries } from "../../../../../managers/timeEntries";
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
import { ActivityController } from "../../../../../controllers/ActivityController";
import { OptimaType } from "../../../../../workflows/wf.opportunity";
import { prisma } from "../../../../../constants";
// ═══════════════════════════════════════════════════════════════════════════
// HELPERS
@@ -168,13 +169,47 @@ export default createRoute(
}),
);
// Resolve CwMember info for all unique memberIds across time entries
const allMemberIds = Array.from(
new Set(
activitiesWithTimeEntries.flatMap((item) =>
item.timeEntries
.map((te) => te.memberId)
.filter((id): id is string => !!id),
),
),
);
const memberRecords = allMemberIds.length
? await prisma.cwMember.findMany({
where: { identifier: { in: allMemberIds } },
select: { identifier: true, firstName: true, lastName: true, officeEmail: true },
})
: [];
const memberMap = new Map(
memberRecords.map((m) => [
m.identifier,
{ name: `${m.firstName} ${m.lastName}`.trim(), email: m.officeEmail ?? null },
]),
);
// Attach member info to each time entry
const enrichedActivities = activitiesWithTimeEntries.map((item) => ({
...item,
timeEntries: item.timeEntries.map((te) => ({
...te,
member: te.memberId ? (memberMap.get(te.memberId) ?? null) : null,
})),
}));
const response = apiResponse.successful(
"Workflow history fetched successfully.",
{
opportunityId: opportunity.id,
cwOpportunityId: opportunity.cwOpportunityId,
totalActivities: activitiesWithTimeEntries.length,
activities: activitiesWithTimeEntries,
totalActivities: enrichedActivities.length,
activities: enrichedActivities,
},
);
return c.json(response, response.status as ContentfulStatusCode);
+19 -9
View File
@@ -11,6 +11,7 @@ import {
createWorkflowActivity,
OptimaType,
} from "../../../workflows/wf.opportunity";
import { resolveCwProbabilityId } from "../../../modules/cw-utils/opportunities/cwProbabilityCache";
const createSchema = z.object({
name: z.string().min(1),
@@ -46,16 +47,25 @@ export default createRoute(
const data = createSchema.parse(body);
const { interest, ...cwCreateData } = data;
try {
const item = await opportunities.createItem(cwCreateData);
// Resolve the CW probability reference ID for 50% (the default)
const defaultProbabilityId = await resolveCwProbabilityId(50);
if (interest !== undefined) {
await prisma.opportunity.update({
where: { uid: item.id },
data: { interest },
});
item.interest = interest;
}
try {
const item = await opportunities.createItem({
...cwCreateData,
...(defaultProbabilityId != null
? { probability: { id: defaultProbabilityId } }
: {}),
});
// Apply defaults: "HOT" interest and 50% probability if not explicitly provided.
const effectiveInterest = interest !== undefined ? interest : "HOT";
await prisma.opportunity.update({
where: { uid: item.id },
data: { interest: effectiveInterest, probability: 50 },
});
item.interest = effectiveInterest;
item.probability = 50;
// Create a workflow activity for the new opportunity
try {
+5 -2
View File
@@ -5,7 +5,6 @@ import {
CatalogManufacturer,
} 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";
@@ -129,7 +128,11 @@ export class CatalogItemController {
* @returns {Promise<CatalogItemController>} - The updated controller
*/
public async refreshInventory(): Promise<CatalogItemController> {
const onHand = await catalogCw.fetchInventoryOnHand(this.cwCatalogId);
const result = await prisma.productInventory.aggregate({
where: { itemId: this.cwCatalogId },
_sum: { qtyOnHand: true },
});
const onHand = result._sum.qtyOnHand ?? 0;
if (onHand !== this.onHand) {
await prisma.catalogItem.update({
+1 -1
View File
@@ -1904,7 +1904,7 @@ export class OpportunityController {
expectedSalesTaxRate:
this.taxCodeRate !== null ? this.taxCodeRate * 100 : null,
taxCodeDescription: this.taxCodeDescription,
probability: this.probability,
probability: this.probability != null ? { percent: this.probability } : null,
location: this.locationCwId
? { id: this.locationCwId, name: this.locationName }
: null,
@@ -0,0 +1,63 @@
/**
* cwProbabilityCache
*
* Fetches and caches the list of ConnectWise probability dropdown options.
* Used to resolve a numeric percent (0100) to the CW probability reference ID
* required when creating or updating opportunities via the REST API.
*
* CW endpoint: GET /sales/probabilities
* Returns: [{ id: number, probability: number }]
*/
import { connectWiseApi } from "../../../constants";
interface CWProbability {
id: number;
probability: number;
}
let _cache: CWProbability[] | null = null;
async function fetchProbabilities(): Promise<CWProbability[]> {
if (_cache) return _cache;
const response = await connectWiseApi.get<CWProbability[]>(
"/sales/probabilities",
{ params: { pageSize: 1000 } }
);
_cache = response.data;
return _cache;
}
/**
* Resolve the CW probability reference ID for a given percent value.
*
* Finds an exact match first; if none, returns the closest option.
* Returns null if the probabilities list is empty.
*/
export async function resolveCwProbabilityId(
percent: number
): Promise<number | null> {
const list = await fetchProbabilities();
if (list.length === 0) return null;
// Exact match
const exact = list.find((p) => p.probability === percent);
if (exact) return exact.id;
// Closest match
let closest = list[0]!;
let minDiff = Math.abs(closest.probability - percent);
for (const option of list) {
const diff = Math.abs(option.probability - percent);
if (diff < minDiff) {
minDiff = diff;
closest = option;
}
}
return closest.id;
}
/** Clear the cache (useful for tests or forced refresh). */
export function clearCwProbabilityCache(): void {
_cache = null;
}
@@ -272,6 +272,7 @@ export interface CWOpportunityUpdate {
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
probability?: { id: number };
campaign?: { id: number };
primarySalesRep?: { id: number };
secondarySalesRep?: { id: number } | null;
@@ -296,6 +297,7 @@ export interface CWOpportunityCreate {
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
probability?: { id: number };
campaign?: { id: number };
secondarySalesRep?: { id: number } | null;
site?: { id: number } | null;