feat: expand sales opportunity workflow and metrics APIs

This commit is contained in:
2026-03-15 23:38:56 -05:00
parent 33b34d08a7
commit e764932c39
55 changed files with 3425 additions and 157 deletions
+2 -2
View File
@@ -20,10 +20,10 @@ import {
} from "../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
export default createRoute(
"get",
["/opportunities/:identifier"],
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
+6
View File
@@ -1,4 +1,5 @@
import { default as fetchAll } from "./opportunities/fetchAll";
import { default as metrics } from "./opportunities/metrics";
import { default as createOpportunity } from "./opportunities/create";
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
import { default as count } from "./opportunities/count";
@@ -26,18 +27,23 @@ import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
import { default as fetchByUser } from "./opportunities/fetchByUser";
import { default as fetchByUserId } from "./opportunities/fetchByUserId";
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
import { default as workflowStatus } from "./opportunities/[id]/workflow/status";
import { default as workflowHistory } from "./opportunities/[id]/workflow/history";
export {
addProduct,
fetchByUser,
fetchByUserId,
addLabor,
laborOptions,
addSpecialOrderProduct,
count,
createOpportunity,
deleteOpportunity,
metrics,
fetch,
fetchAll,
fetchOpportunityTypes,
+2 -2
View File
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/contacts */
/* GET /v1/sales/opportunities/opportunity/:identifier/contacts */
export default createRoute(
"get",
["/opportunities/:identifier/contacts"],
["/opportunities/opportunity/:identifier/contacts"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
+2 -2
View File
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import GenericError from "../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier */
/* DELETE /v1/sales/opportunities/opportunity/:identifier */
export default createRoute(
"delete",
["/opportunities/:identifier"],
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
+2 -2
View File
@@ -20,10 +20,10 @@ import {
} from "../../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
export default createRoute(
"get",
["/opportunities/:identifier"],
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
@@ -6,10 +6,10 @@ import { authMiddleware } from "../../../../middleware/authorization";
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* POST /v1/sales/opportunities/:identifier/notes */
/* POST /v1/sales/opportunities/opportunity/:identifier/notes */
export default createRoute(
"post",
["/opportunities/:identifier/notes"],
["/opportunities/opportunity/:identifier/notes"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
/* DELETE /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"delete",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
/* GET /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"get",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/notes */
/* GET /v1/sales/opportunities/opportunity/:identifier/notes */
export default createRoute(
"get",
["/opportunities/:identifier/notes"],
["/opportunities/opportunity/:identifier/notes"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
@@ -7,10 +7,10 @@ import GenericError from "../../../../../Errors/GenericError";
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"patch",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -34,10 +34,10 @@ const addProductSchema = z.union([
z.array(productItemSchema).min(1, "At least one product is required"),
]);
/* POST /v1/sales/opportunities/:identifier/products */
/* POST /v1/sales/opportunities/opportunity/:identifier/products */
export default createRoute(
"post",
["/opportunities/:identifier/products"],
["/opportunities/opportunity/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -30,10 +30,10 @@ const addLaborSchema = z
})
.strict();
/* POST /v1/sales/opportunities/:identifier/products/labor */
/* POST /v1/sales/opportunities/opportunity/:identifier/products/labor */
export default createRoute(
"post",
["/opportunities/:identifier/products/labor"],
["/opportunities/opportunity/:identifier/products/labor"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -27,10 +27,10 @@ const addSpecialOrderSchema = z.union([
.min(1, "At least one special-order product is required"),
]);
/* POST /v1/sales/opportunities/:identifier/products/special-order */
/* POST /v1/sales/opportunities/opportunity/:identifier/products/special-order */
export default createRoute(
"post",
["/opportunities/:identifier/products/special-order"],
["/opportunities/opportunity/:identifier/products/special-order"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -13,10 +13,10 @@ const cancelProductSchema = z
})
.strict();
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/cancel */
export default createRoute(
"patch",
["/opportunities/:identifier/products/:productId/cancel"],
["/opportunities/opportunity/:identifier/products/:productId/cancel"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/products/:productId */
/* DELETE /v1/sales/opportunities/opportunity/:identifier/products/:productId */
export default createRoute(
"delete",
["/opportunities/:identifier/products/:productId"],
["/opportunities/opportunity/:identifier/products/:productId"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products */
/* GET /v1/sales/opportunities/opportunity/:identifier/products */
export default createRoute(
"get",
["/opportunities/:identifier/products"],
["/opportunities/opportunity/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
/* GET /v1/sales/opportunities/opportunity/:identifier/products/labor/options */
export default createRoute(
"get",
["/opportunities/:identifier/products/labor/options"],
["/opportunities/opportunity/:identifier/products/labor/options"],
async (c) => {
const identifier = c.req.param("identifier");
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/sequence */
export default createRoute(
"patch",
["/opportunities/:identifier/products/sequence"],
["/opportunities/opportunity/:identifier/products/sequence"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -56,10 +56,10 @@ const upsertCustomTextField = (
return next;
};
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/edit */
export default createRoute(
"patch",
["/opportunities/:identifier/products/:productId/edit"],
["/opportunities/opportunity/:identifier/products/:productId/edit"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
@@ -19,10 +19,10 @@ const commitQuoteSchema = z
.strict()
.optional();
/* POST /v1/sales/opportunities/:identifier/quote/commit */
/* POST /v1/sales/opportunities/opportunity/:identifier/quote/commit */
export default createRoute(
"post",
["/opportunities/:identifier/quote/commit"],
["/opportunities/opportunity/:identifier/quote/commit"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json().catch(() => undefined);
@@ -9,10 +9,10 @@ import GenericError from "../../../../../Errors/GenericError";
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/download?fetchAction=download|print */
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/download?fetchAction=download|print */
export default createRoute(
"get",
["/opportunities/:identifier/quote/:quoteId/download"],
["/opportunities/opportunity/:identifier/quote/:quoteId/download"],
async (c) => {
const quoteId = c.req.param("quoteId");
const user = c.get("user");
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quotes */
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes */
export default createRoute(
"get",
["/opportunities/:identifier/quotes"],
["/opportunities/opportunity/:identifier/quotes"],
async (c) => {
const identifier = c.req.param("identifier");
const includeRegenData = c.req.query("includeRegenData") === "true";
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quotes/downloads */
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes/downloads */
export default createRoute(
"get",
["/opportunities/:identifier/quotes/downloads"],
["/opportunities/opportunity/:identifier/quotes/downloads"],
async (c) => {
const identifier = c.req.param("identifier");
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/preview */
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview */
export default createRoute(
"get",
["/opportunities/:identifier/quote/:quoteId/preview"],
["/opportunities/opportunity/:identifier/quote/:quoteId/preview"],
async (c) => {
const identifier = c.req.param("identifier");
const quoteId = c.req.param("quoteId");
+2 -2
View File
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
/* POST /v1/sales/opportunities/:identifier/refresh */
/* POST /v1/sales/opportunities/opportunity/:identifier/refresh */
export default createRoute(
"post",
["/opportunities/:identifier/refresh"],
["/opportunities/opportunity/:identifier/refresh"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier);
+2 -2
View File
@@ -34,10 +34,10 @@ const updateSchema = z
message: "At least one field must be provided",
});
/* PATCH /v1/sales/opportunities/:identifier */
/* PATCH /v1/sales/opportunities/opportunity/:identifier */
export default createRoute(
"patch",
["/opportunities/:identifier"],
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -51,6 +51,10 @@ const dispatchSchema = z.discriminatedUnion("action", [
needsRevision: z.boolean().optional(),
}),
}),
z.object({
action: z.literal("markReadyToSend"),
payload: basePayload,
}),
z.object({
action: z.literal("confirmQuote"),
payload: basePayload,
@@ -91,10 +95,10 @@ const dispatchSchema = z.discriminatedUnion("action", [
// ── Route ─────────────────────────────────────────────────────────────────
/* POST /v1/sales/opportunities/:identifier/workflow */
/* POST /v1/sales/opportunities/opportunity/:identifier/workflow */
export default createRoute(
"post",
["/opportunities/:identifier/workflow"],
["/opportunities/opportunity/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
@@ -73,10 +73,10 @@ function extractCloseDate(
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/:identifier/workflow/history */
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow/history */
export default createRoute(
"get",
["/opportunities/:identifier/workflow/history"],
["/opportunities/opportunity/:identifier/workflow/history"],
async (c) => {
try {
const identifier = c.req.param("identifier");
@@ -47,6 +47,15 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
],
[OpportunityStatus.New]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "sendQuote",
label: "Send Quote (skip review)",
@@ -81,9 +90,9 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
[OpportunityStatus.InternalReview]: [
{
action: "reviewDecision",
label: "Approve (move to Pending Sent)",
label: "Approve (move to Ready to Send)",
targetStatuses: [
{ key: "PendingSent", id: OpportunityStatus.PendingSent },
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: true,
requiresPermission: null,
@@ -117,7 +126,32 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
},
],
[OpportunityStatus.ReadyToSend]: [
{
action: "sendQuote",
label: "Send Quote to Customer",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
needsRevision: "boolean — needs revision",
},
},
],
[OpportunityStatus.PendingSent]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "sendQuote",
label: "Send Quote to Customer",
@@ -217,6 +251,15 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
],
[OpportunityStatus.Active]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "resendQuote",
label: "Send Revised Quote",
@@ -294,10 +337,10 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/:identifier/workflow */
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow */
export default createRoute(
"get",
["/opportunities/:identifier/workflow"],
["/opportunities/opportunity/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
@@ -0,0 +1,25 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/@me */
export default createRoute(
"get",
["/opportunities/@me"],
async (c) => {
const user = c.get("user");
const includeClosed = c.req.query("includeClosed") === "true";
const data = await opportunities.fetchByUser(user.id, { includeClosed });
const response = apiResponse.successful(
"Opportunities fetched successfully!",
data.map((item) => item.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
);
@@ -0,0 +1,25 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/user/:id */
export default createRoute(
"get",
["/opportunities/user/:id"],
async (c) => {
const userId = c.req.param("id");
const includeClosed = c.req.query("includeClosed") === "true";
const data = await opportunities.fetchByUser(userId, { includeClosed });
const response = apiResponse.successful(
"Opportunities fetched successfully!",
data.map((item) => item.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
);
+95
View File
@@ -0,0 +1,95 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import {
getSalesOpportunityMetricsAll,
getSalesOpportunityMetricsForMember,
refreshSalesOpportunityMetricsCache,
} from "../../../modules/cache/salesOpportunityMetricsCache";
/* GET /v1/sales/opportunities/metrics */
export default createRoute(
"get",
["/opportunities/metrics"],
async (c) => {
const user = c.get("user");
const scope = (c.req.query("scope") ?? "me").toLowerCase();
const requestedIdentifier = c.req.query("identifier")?.trim().toLowerCase();
const currentUserIdentifier = user?.cwIdentifier?.trim().toLowerCase();
if (
scope === "all" &&
!(await user.hasPermission("sales.opportunity.metrics.all"))
) {
throw new GenericError({
name: "InsufficientPermission",
message:
"You do not have permission to view metrics for all active members.",
status: 403,
});
}
const usingIdentifierOverride =
scope !== "all" &&
!!requestedIdentifier &&
requestedIdentifier !== currentUserIdentifier;
if (
usingIdentifierOverride &&
!(await user.hasPermission(
"sales.opportunity.metrics.identifier.override",
))
) {
throw new GenericError({
name: "InsufficientPermission",
message:
"You do not have permission to query metrics by overriding the member identifier.",
status: 403,
});
}
const requireWarmCache = async () => {
const all = await getSalesOpportunityMetricsAll();
if (all) return all;
await refreshSalesOpportunityMetricsCache();
return getSalesOpportunityMetricsAll();
};
if (scope === "all") {
const all = await requireWarmCache();
const response = apiResponse.successful(
"Sales opportunity metrics fetched successfully!",
all,
);
return c.json(response, response.status as ContentfulStatusCode);
}
const targetIdentifier = requestedIdentifier ?? currentUserIdentifier;
if (!targetIdentifier) {
const response = apiResponse.successful(
"Sales opportunity metrics fetched successfully!",
null,
);
return c.json(response, response.status as ContentfulStatusCode);
}
let metrics = await getSalesOpportunityMetricsForMember(targetIdentifier);
if (!metrics) {
await refreshSalesOpportunityMetricsCache();
metrics = await getSalesOpportunityMetricsForMember(targetIdentifier);
}
const response = apiResponse.successful(
"Sales opportunity metrics fetched successfully!",
{
identifier: targetIdentifier,
metrics,
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
);
+38 -6
View File
@@ -45,6 +45,8 @@ import {
type QuoteMetadata,
} from "../modules/pdf-utils";
import { generatedQuotes } from "../managers/generatedQuotes";
import { getExpectedSalesTaxRate } from "../modules/sales-utils/expectedSalesTax";
import { normalizeProbabilityPercent } from "../modules/sales-utils/normalizeProbability";
/**
* Opportunity Controller
@@ -106,6 +108,7 @@ export class OpportunityController {
public companyId: string | null;
public cwLastUpdated: Date | null;
public cwDateEntered: Date | null;
// Local product display order — array of CW forecast item IDs.
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
@@ -223,6 +226,7 @@ export class OpportunityController {
this.companyId = data.companyId;
this.cwLastUpdated = data.cwLastUpdated;
this.cwDateEntered = data.cwDateEntered ?? null;
this.productSequence = data.productSequence;
this.createdAt = data.createdAt;
@@ -366,7 +370,7 @@ export class OpportunityController {
customerPO: item.customerPO ?? null,
totalSalesTax: item.totalSalesTax ?? 0,
probability: Number(item.probability?.name) || 0,
probability: normalizeProbabilityPercent(item.probability?.name),
locationName: item.location?.name ?? null,
locationCwId: item.location?.id ?? null,
@@ -390,6 +394,9 @@ export class OpportunityController {
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
cwDateEntered: item._info?.dateEntered
? new Date(item._info.dateEntered)
: null,
};
}
@@ -690,6 +697,17 @@ export class OpportunityController {
};
});
const taxableSubTotal = activeProducts.reduce((sum, item) => {
if (!item.taxableFlag) return sum;
const isLabor = item.productClass === "Service";
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
return sum + (isLabor ? 1 : quantity) * unitPrice;
}, 0);
const quoteDescription = this.name;
const primaryContactFullName = [
@@ -705,12 +723,9 @@ export class OpportunityController {
this.companyName ||
"Customer";
const subTotal = lineItems.reduce(
(sum, item) => sum + item.qty * item.unitPrice,
0,
const normalizedTaxRate = getExpectedSalesTaxRate(
site?.address ?? companyJson?.cw_Data?.address,
);
const normalizedTaxRate =
subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0;
const taxLabel =
normalizedTaxRate > 0
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
@@ -778,6 +793,7 @@ export class OpportunityController {
description: quoteDescription,
},
lineItems,
taxableSubtotal: taxableSubTotal,
quoteNarrative,
tax: {
rate: normalizedTaxRate,
@@ -1542,6 +1558,21 @@ export class OpportunityController {
* Serializes the opportunity into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
const siteAddress = this._siteData?.address;
const companyAddress = this._company?.cw_Data?.company
? {
line1: this._company.cw_Data.company.addressLine1,
line2: this._company.cw_Data.company.addressLine2,
city: this._company.cw_Data.company.city,
state: this._company.cw_Data.company.state,
zip: this._company.cw_Data.company.zip,
country: this._company.cw_Data.company.country?.name ?? null,
}
: null;
const expectedSalesTax = getExpectedSalesTaxRate(
siteAddress ?? companyAddress,
);
return {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
@@ -1597,6 +1628,7 @@ export class OpportunityController {
: null,
customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax,
expectedSalesTax,
probability: this.probability,
location: this.locationCwId
? { id: this.locationCwId, name: this.locationName }
+17
View File
@@ -16,6 +16,7 @@ import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventor
import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/listenInventoryAdjustments";
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
import { refreshSalesOpportunityMetricsCache } from "./modules/cache/salesOpportunityMetricsCache";
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
@@ -178,6 +179,22 @@ setInterval(
5 * 60 * 1000,
);
// Refresh sales opportunity metrics cache for active CW members every 5 minutes
await safeStartup(
"refreshSalesOpportunityMetricsCache",
() => refreshSalesOpportunityMetricsCache({ forceColdLoad: true }),
);
setInterval(
() => {
return refreshSalesOpportunityMetricsCache().catch((err) =>
console.error(
`[interval] refreshSalesOpportunityMetricsCache failed: ${briefErr(err)}`,
),
);
},
5 * 60 * 1000,
);
// Refresh CW identifiers for all users every 30 minutes
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
setInterval(
+67 -5
View File
@@ -351,7 +351,7 @@ export const opportunities = {
const skip = (Math.max(page, 1) - 1) * rpp;
const items = await prisma.opportunity.findMany({
where: opts?.includeClosed ? undefined : { closedFlag: false },
where: opts?.includeClosed ? undefined : { closedDate: null },
include: { company: true },
skip,
take: rpp,
@@ -401,7 +401,7 @@ export const opportunities = {
const items = await prisma.opportunity.findMany({
where: {
...(opts?.includeClosed ? {} : { closedFlag: false }),
...(opts?.includeClosed ? {} : { closedDate: null }),
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ companyName: { contains: query, mode: "insensitive" } },
@@ -445,7 +445,7 @@ export const opportunities = {
*/
async count(opts?: { openOnly?: boolean }): Promise<number> {
return prisma.opportunity.count({
where: opts?.openOnly ? { closedFlag: false } : undefined,
where: opts?.openOnly ? { closedDate: null } : undefined,
});
},
@@ -469,7 +469,7 @@ export const opportunities = {
return prisma.opportunity.count({
where: {
...(opts?.includeClosed ? {} : { closedFlag: false }),
...(opts?.includeClosed ? {} : { closedDate: null }),
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ companyName: { contains: query, mode: "insensitive" } },
@@ -504,7 +504,7 @@ export const opportunities = {
const items = await prisma.opportunity.findMany({
where: {
companyId,
...(opts?.includeClosed ? {} : { closedFlag: false }),
...(opts?.includeClosed ? {} : { closedDate: null }),
},
include: { company: true },
orderBy: { expectedCloseDate: "asc" },
@@ -526,6 +526,68 @@ export const opportunities = {
);
},
/**
* Fetch Opportunities by User
*
* Returns all opportunities where the given user (by internal User ID) is
* assigned as the primary or secondary sales rep. Resolves the user's
* ConnectWise member identifier from the DB, then queries opportunities by
* that identifier.
*
* Uses the **cache-only** strategy (same as `fetchPages`).
*
* @param userId - Internal User `id` (cuid)
* @param opts - Optional filters
* @returns {Promise<OpportunityController[]>}
*/
async fetchByUser(
userId: string,
opts?: { includeClosed?: boolean },
): Promise<OpportunityController[]> {
const user = await prisma.user.findFirst({
where: { id: userId },
select: { cwIdentifier: true },
});
if (!user) {
throw new GenericError({
message: "User not found",
name: "UserNotFound",
cause: `No user exists with id '${userId}'`,
status: 404,
});
}
if (!user.cwIdentifier) return [];
const items = await prisma.opportunity.findMany({
where: {
OR: [
{ primarySalesRepIdentifier: user.cwIdentifier },
{ secondarySalesRepIdentifier: user.cwIdentifier },
],
...(opts?.includeClosed ? {} : { closedDate: null }),
},
include: { company: true },
orderBy: { createdAt: "desc" },
});
return Promise.all(
items.map(async (item) =>
new OpportunityController(item, {
company: item.company
? await buildCompanyController(item.company, {
strategy: "cache-only",
})
: undefined,
activities: await buildActivities(item.cwOpportunityId, {
strategy: "cache-only",
}),
}),
),
);
},
/**
* Delete Opportunity
*
+900
View File
@@ -0,0 +1,900 @@
import { prisma, redis } from "../../constants";
import { getCachedOppCwData, getCachedProducts } from "./opportunityCache";
import { OpportunityStatus } from "../../workflows/wf.opportunity";
import { events } from "../globalEvents";
import { opportunities } from "../../managers/opportunities";
import { normalizeProbabilityRatio } from "../sales-utils/normalizeProbability";
const METRICS_CACHE_TTL_MS = 10 * 60 * 1000;
const ALL_MEMBERS_KEY = "sales:metrics:members:all";
const MEMBER_KEY_PREFIX = "sales:metrics:member:";
const OPP_REVENUE_KEY_PREFIX = "sales:metrics:oppRevenue:";
const PRODUCT_FETCH_CONCURRENCY = 6;
const PRODUCT_LOOKUP_TIMEOUT_MS = 35_000;
const LOG_PREFIX = "[cache:salesMetrics]";
const log = (message: string) => {
const ts = new Date().toISOString();
console.log(`${LOG_PREFIX} ${ts} ${message}`);
};
let salesMetricsRefreshInFlight: Promise<void> | null = null;
const memberKey = (identifier: string) =>
`${MEMBER_KEY_PREFIX}${identifier.toLowerCase()}`;
const oppRevenueKey = (cwOpportunityId: number) =>
`${OPP_REVENUE_KEY_PREFIX}${cwOpportunityId}`;
const deleteKeysByPrefix = async (prefix: string) => {
const keys = await redis.keys(`${prefix}*`);
if (keys.length === 0) return 0;
await redis.del(...keys);
return keys.length;
};
export interface OpportunityBreakdownEntry {
id: string;
cwId: number;
name: string;
revenue: number;
taxableRevenue: number;
nonTaxableRevenue: number;
/** Probability as a 0100 percent value */
probability: number;
weightedRevenue: number;
closedDate: string | null;
}
export interface MemberSalesMetrics {
memberIdentifier: string;
memberName: string;
generatedAt: string;
pipelineRevenue: number;
closedWonRevenueMtd: number;
closedWonRevenueYtd: number;
winCount: { mtd: number; ytd: number };
lossCount: { mtd: number; ytd: number };
avgDaysToClose: number;
openOpportunityCount: number;
wonOpportunityCount: { mtd: number; ytd: number };
lostOpportunityCount: { mtd: number; ytd: number };
closedOpportunityCount: { mtd: number; ytd: number };
weightedPipelineRevenue: number;
taxablePipelineRevenue: number;
nonTaxablePipelineRevenue: number;
avgOpenDealSize: number;
avgWonDealSize: { mtd: number; ytd: number };
winRate: { mtd: number; ytd: number };
lossRate: { mtd: number; ytd: number };
assignedOpportunityCount: number;
cacheHitCount: number;
cacheMissCount: number;
cacheHitRate: number;
opportunityBreakdown: {
pipeline: OpportunityBreakdownEntry[];
closedWonMtd: OpportunityBreakdownEntry[];
closedWonYtd: OpportunityBreakdownEntry[];
closedLostMtd: OpportunityBreakdownEntry[];
closedLostYtd: OpportunityBreakdownEntry[];
};
}
export interface SalesMetricsCacheEnvelope {
generatedAt: string;
activeMemberCount: number;
memberIdentifiers: string[];
members: Record<string, MemberSalesMetrics>;
}
interface OpportunityRevenue {
totalRevenue: number;
taxableRevenue: number;
nonTaxableRevenue: number;
cacheHit: boolean;
}
interface CachedOpportunityRevenue {
totalRevenue: number;
taxableRevenue: number;
nonTaxableRevenue: number;
}
interface OpportunityRow {
id: string;
cwOpportunityId: number;
name: string;
primarySalesRepIdentifier: string | null;
secondarySalesRepIdentifier: string | null;
statusCwId: number | null;
statusName: string | null;
closedFlag: boolean;
dateBecameLead: Date | null;
closedDate: Date | null;
probability: number;
}
interface RefreshSalesOpportunityMetricsCacheOptions {
forceColdLoad?: boolean;
}
const roundCurrency = (value: number) => Math.round(value * 100) / 100;
const daysBetween = (start: Date, end: Date): number => {
const msPerDay = 1000 * 60 * 60 * 24;
return Math.max(0, (end.getTime() - start.getTime()) / msPerDay);
};
const startOfMonthUtc = (input: Date): Date =>
new Date(Date.UTC(input.getUTCFullYear(), input.getUTCMonth(), 1, 0, 0, 0));
const startOfYearUtc = (input: Date): Date =>
new Date(Date.UTC(input.getUTCFullYear(), 0, 1, 0, 0, 0));
const toFinite = (value: unknown): number => {
const n = Number(value);
if (!Number.isFinite(n)) return 0;
return n;
};
const isWon = (opp: {
statusCwId: number | null;
statusName: string | null;
closedFlag: boolean;
}) => {
if (opp.statusCwId === OpportunityStatus.Won) return true;
if (opp.statusName?.toLowerCase().includes("won")) return true;
if (opp.closedFlag && opp.statusName?.toLowerCase().includes("won"))
return true;
return false;
};
const isLost = (opp: {
statusCwId: number | null;
statusName: string | null;
closedFlag: boolean;
}) => {
if (opp.statusCwId === OpportunityStatus.Lost) return true;
if (opp.statusName?.toLowerCase().includes("lost")) return true;
if (opp.closedFlag && opp.statusName?.toLowerCase().includes("lost"))
return true;
return false;
};
const isClosedOpportunity = (opp: {
statusCwId: number | null;
statusName: string | null;
closedFlag: boolean;
}) => {
if (opp.closedFlag) return true;
if (isWon(opp)) return true;
if (isLost(opp)) return true;
return false;
};
const buildCancellationMap = (procProducts: any[]) => {
const map = new Map<number, any>();
for (const pp of procProducts) {
const rawForecastDetailId = pp?.forecastDetailId;
const forecastDetailId =
typeof rawForecastDetailId === "number"
? rawForecastDetailId
: Number(rawForecastDetailId);
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
map.set(forecastDetailId, pp);
}
}
return map;
};
const computeRevenueFromProductsBlob = (
blob: any,
): Omit<OpportunityRevenue, "cacheHit"> => {
const forecastItems = Array.isArray(blob?.forecast?.forecastItems)
? blob.forecast.forecastItems
: [];
const procProducts = Array.isArray(blob?.procProducts)
? blob.procProducts
: [];
const cancellationMap = buildCancellationMap(procProducts);
let totalRevenue = 0;
let taxableRevenue = 0;
for (const item of forecastItems) {
if (!cancellationMap.has(item?.id)) continue;
if (!item?.includeFlag) continue;
const quantity = Math.max(0, toFinite(item?.quantity));
const revenue = toFinite(item?.revenue);
const cancellation = cancellationMap.get(item.id);
const cancelledFlag = Boolean(cancellation?.cancelledFlag);
const quantityCancelled = Math.max(
0,
toFinite(cancellation?.quantityCancelled),
);
if (cancelledFlag && quantity > 0 && quantityCancelled >= quantity)
continue;
const ratio =
quantity > 0 ? Math.max(0, (quantity - quantityCancelled) / quantity) : 1;
const effectiveRevenue = revenue * ratio;
totalRevenue += effectiveRevenue;
if (item?.taxableFlag) taxableRevenue += effectiveRevenue;
}
const nonTaxableRevenue = totalRevenue - taxableRevenue;
return {
totalRevenue: roundCurrency(totalRevenue),
taxableRevenue: roundCurrency(taxableRevenue),
nonTaxableRevenue: roundCurrency(nonTaxableRevenue),
};
};
const computeRevenueFromControllers = (
products: Array<{
includeFlag: boolean;
taxableFlag: boolean;
cancellationType: "full" | "partial" | null;
effectiveRevenue: number;
}>,
): Omit<OpportunityRevenue, "cacheHit"> => {
let totalRevenue = 0;
let taxableRevenue = 0;
for (const item of products) {
if (!item.includeFlag) continue;
if (item.cancellationType === "full") continue;
const effectiveRevenue = Math.max(0, toFinite(item.effectiveRevenue));
totalRevenue += effectiveRevenue;
if (item.taxableFlag) taxableRevenue += effectiveRevenue;
}
const nonTaxableRevenue = totalRevenue - taxableRevenue;
return {
totalRevenue: roundCurrency(totalRevenue),
taxableRevenue: roundCurrency(taxableRevenue),
nonTaxableRevenue: roundCurrency(nonTaxableRevenue),
};
};
const readCachedOpportunityRevenue = async (
cwOpportunityId: number,
): Promise<CachedOpportunityRevenue | null> => {
const raw = await redis.get(oppRevenueKey(cwOpportunityId));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as CachedOpportunityRevenue;
return {
totalRevenue: toFinite(parsed.totalRevenue),
taxableRevenue: toFinite(parsed.taxableRevenue),
nonTaxableRevenue: toFinite(parsed.nonTaxableRevenue),
};
} catch {
return null;
}
};
const writeCachedOpportunityRevenue = async (
cwOpportunityId: number,
revenue: Omit<OpportunityRevenue, "cacheHit">,
) => {
await redis.set(
oppRevenueKey(cwOpportunityId),
JSON.stringify(revenue),
"PX",
METRICS_CACHE_TTL_MS,
);
};
const resolveProbabilityRatio = async (opp: {
cwOpportunityId: number;
probability: number;
}): Promise<number> => {
const fromDb = normalizeProbabilityRatio(opp.probability);
if (fromDb > 0) return fromDb;
const cachedCwOpp = await getCachedOppCwData(opp.cwOpportunityId);
if (!cachedCwOpp) return 0;
const rawProbability =
cachedCwOpp?.probability?.name ?? cachedCwOpp?.probability ?? 0;
return normalizeProbabilityRatio(rawProbability);
};
const getOpportunityRevenueCacheFirst = async (
cwOpportunityId: number,
opts?: RefreshSalesOpportunityMetricsCacheOptions,
): Promise<OpportunityRevenue> => {
if (!opts?.forceColdLoad) {
const cachedRevenue = await readCachedOpportunityRevenue(cwOpportunityId);
if (cachedRevenue) {
return {
...cachedRevenue,
cacheHit: true,
};
}
}
if (!opts?.forceColdLoad) {
const cachedProducts = await getCachedProducts(cwOpportunityId);
if (cachedProducts) {
const computed = computeRevenueFromProductsBlob(cachedProducts);
await writeCachedOpportunityRevenue(cwOpportunityId, computed);
return {
...computed,
cacheHit: true,
};
}
}
try {
const opportunity = await opportunities.fetchRecord(cwOpportunityId);
const products = await opportunity.fetchProducts({
fresh: opts?.forceColdLoad,
});
const computed = computeRevenueFromControllers(products);
await writeCachedOpportunityRevenue(cwOpportunityId, computed);
return {
...computed,
cacheHit: false,
};
} catch {
return {
totalRevenue: 0,
taxableRevenue: 0,
nonTaxableRevenue: 0,
cacheHit: false,
};
}
};
const withTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error("Timeout")), timeoutMs);
}),
]);
};
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
mapper: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let index = 0;
const worker = async () => {
while (true) {
const current = index;
index += 1;
if (current >= items.length) return;
results[current] = await mapper(items[current]!);
}
};
const workers = Array.from(
{ length: Math.min(concurrency, items.length) },
() => worker(),
);
await Promise.all(workers);
return results;
}
const buildEmptyMetrics = (
memberIdentifier: string,
memberName: string,
generatedAt: string,
): MemberSalesMetrics => ({
memberIdentifier,
memberName,
generatedAt,
pipelineRevenue: 0,
closedWonRevenueMtd: 0,
closedWonRevenueYtd: 0,
winCount: { mtd: 0, ytd: 0 },
lossCount: { mtd: 0, ytd: 0 },
avgDaysToClose: 0,
openOpportunityCount: 0,
wonOpportunityCount: { mtd: 0, ytd: 0 },
lostOpportunityCount: { mtd: 0, ytd: 0 },
closedOpportunityCount: { mtd: 0, ytd: 0 },
weightedPipelineRevenue: 0,
taxablePipelineRevenue: 0,
nonTaxablePipelineRevenue: 0,
avgOpenDealSize: 0,
avgWonDealSize: { mtd: 0, ytd: 0 },
winRate: { mtd: 0, ytd: 0 },
lossRate: { mtd: 0, ytd: 0 },
assignedOpportunityCount: 0,
cacheHitCount: 0,
cacheMissCount: 0,
cacheHitRate: 0,
opportunityBreakdown: {
pipeline: [],
closedWonMtd: [],
closedWonYtd: [],
closedLostMtd: [],
closedLostYtd: [],
},
});
export async function refreshSalesOpportunityMetricsCache(
opts?: RefreshSalesOpportunityMetricsCacheOptions,
): Promise<void> {
if (salesMetricsRefreshInFlight) {
log(
"refresh requested while previous run is still in-flight; reusing existing run",
);
return salesMetricsRefreshInFlight;
}
salesMetricsRefreshInFlight = (async () => {
const startedAt = Date.now();
const forceColdLoad = opts?.forceColdLoad === true;
log(`refresh started${forceColdLoad ? " | mode=cold" : " | mode=warm"}`);
if (forceColdLoad) {
const [deletedMemberKeys, deletedRevenueKeys] = await Promise.all([
deleteKeysByPrefix(MEMBER_KEY_PREFIX),
deleteKeysByPrefix(OPP_REVENUE_KEY_PREFIX),
redis.del(ALL_MEMBERS_KEY),
]);
log(
`cold-load reset completed: memberKeysCleared=${deletedMemberKeys} oppRevenueKeysCleared=${deletedRevenueKeys}`,
);
}
const now = new Date();
const generatedAt = now.toISOString();
const monthStart = startOfMonthUtc(now);
const yearStart = startOfYearUtc(now);
try {
const activeMembers = await prisma.cwMember.findMany({
where: { inactiveFlag: false },
select: {
identifier: true,
firstName: true,
lastName: true,
},
});
const memberIdentifiers = activeMembers.map(
(member) => member.identifier,
);
log(`members fetched: activeMembers=${memberIdentifiers.length}`);
const opportunityRows: OpportunityRow[] =
await prisma.opportunity.findMany({
where: {
AND: [
{
OR: [
{ primarySalesRepIdentifier: { in: memberIdentifiers } },
{ secondarySalesRepIdentifier: { in: memberIdentifiers } },
],
},
{ dateBecameLead: { gte: yearStart } },
{
OR: [{ closedFlag: false }, { closedDate: { gte: yearStart } }],
},
],
},
select: {
id: true,
cwOpportunityId: true,
name: true,
primarySalesRepIdentifier: true,
secondarySalesRepIdentifier: true,
statusCwId: true,
statusName: true,
closedFlag: true,
dateBecameLead: true,
closedDate: true,
probability: true,
},
});
log(
`opportunities fetched: assignedOpportunityRows=${opportunityRows.length}`,
);
events.emit("cache:salesMetrics:refresh:started", {
activeMemberCount: memberIdentifiers.length,
opportunityCount: opportunityRows.length,
});
if (memberIdentifiers.length === 0) {
const emptyEnvelope: SalesMetricsCacheEnvelope = {
generatedAt,
activeMemberCount: 0,
memberIdentifiers: [],
members: {},
};
await redis.set(
ALL_MEMBERS_KEY,
JSON.stringify(emptyEnvelope),
"PX",
METRICS_CACHE_TTL_MS,
);
events.emit("cache:salesMetrics:refresh:completed", {
activeMemberCount: 0,
opportunityCount: 0,
memberMetricsWritten: 0,
cacheHitCount: 0,
cacheMissCount: 0,
durationMs: Date.now() - startedAt,
});
log("no active members found; wrote empty cache envelope");
return;
}
const revenuePhaseStartedAt = Date.now();
let revenueLookupProcessed = 0;
let revenueLookupTimeouts = 0;
let revenueLookupFailures = 0;
let revenueLookupCacheHits = 0;
let revenueLookupCacheMisses = 0;
log(
`revenue lookup phase started: concurrency=${PRODUCT_FETCH_CONCURRENCY} timeoutMs=${PRODUCT_LOOKUP_TIMEOUT_MS}`,
);
const revenueRows = await mapWithConcurrency(
opportunityRows,
PRODUCT_FETCH_CONCURRENCY,
async (opp) => {
const [revenue, probabilityRatio] = await Promise.all([
withTimeout(
getOpportunityRevenueCacheFirst(opp.cwOpportunityId, {
forceColdLoad,
}),
PRODUCT_LOOKUP_TIMEOUT_MS,
).catch((err: any) => {
if (err?.message === "Timeout") {
revenueLookupTimeouts += 1;
}
if (err?.message !== "Timeout") {
revenueLookupFailures += 1;
}
return {
totalRevenue: 0,
taxableRevenue: 0,
nonTaxableRevenue: 0,
cacheHit: false,
};
}),
resolveProbabilityRatio(opp),
]);
revenueLookupProcessed += 1;
if (revenue.cacheHit) revenueLookupCacheHits += 1;
if (!revenue.cacheHit) revenueLookupCacheMisses += 1;
if (revenueLookupProcessed % 100 === 0) {
log(
`revenue lookup progress: processed=${revenueLookupProcessed}/${opportunityRows.length} cacheHits=${revenueLookupCacheHits} cacheMisses=${revenueLookupCacheMisses} timeouts=${revenueLookupTimeouts} failures=${revenueLookupFailures}`,
);
}
return { oppId: opp.id, revenue, probabilityRatio };
},
);
log(
`revenue lookup phase completed in ${Date.now() - revenuePhaseStartedAt}ms: processed=${revenueLookupProcessed}/${opportunityRows.length} cacheHits=${revenueLookupCacheHits} cacheMisses=${revenueLookupCacheMisses} timeouts=${revenueLookupTimeouts} failures=${revenueLookupFailures}`,
);
const revenueByOppId = new Map(
revenueRows.map((row) => [row.oppId, row.revenue]),
);
const probabilityByOppId = new Map(
revenueRows.map((row) => [row.oppId, row.probabilityRatio]),
);
const opportunitiesByMember = new Map<string, OpportunityRow[]>();
for (const identifier of memberIdentifiers) {
opportunitiesByMember.set(identifier, []);
}
for (const opp of opportunityRows) {
const assigned = new Set<string>();
if (opp.primarySalesRepIdentifier)
assigned.add(opp.primarySalesRepIdentifier);
if (opp.secondarySalesRepIdentifier)
assigned.add(opp.secondarySalesRepIdentifier);
for (const identifier of assigned) {
const bucket = opportunitiesByMember.get(identifier);
if (!bucket) continue;
bucket.push(opp);
}
}
const members: Record<string, MemberSalesMetrics> = {};
log("member aggregation phase started");
for (const member of activeMembers) {
const identifier = member.identifier;
const assigned = opportunitiesByMember.get(identifier) ?? [];
const metric = buildEmptyMetrics(
identifier,
`${member.firstName} ${member.lastName}`.trim() || identifier,
generatedAt,
);
let wonDaysSumYtd = 0;
for (const opp of assigned) {
const revenue = revenueByOppId.get(opp.id) ?? {
totalRevenue: 0,
taxableRevenue: 0,
nonTaxableRevenue: 0,
cacheHit: false,
};
metric.cacheHitCount += revenue.cacheHit ? 1 : 0;
metric.cacheMissCount += revenue.cacheHit ? 0 : 1;
const won = isWon(opp);
const lost = isLost(opp);
const closed = isClosedOpportunity(opp);
const probabilityRatio = Math.max(
0,
Math.min(1, toFinite(probabilityByOppId.get(opp.id))),
);
const breakdownEntry: OpportunityBreakdownEntry = {
id: opp.id,
cwId: opp.cwOpportunityId,
name: opp.name,
revenue: revenue.totalRevenue,
taxableRevenue: revenue.taxableRevenue,
nonTaxableRevenue: revenue.nonTaxableRevenue,
probability: roundCurrency(probabilityRatio * 100),
weightedRevenue: roundCurrency(
revenue.totalRevenue * probabilityRatio,
),
closedDate: opp.closedDate?.toISOString() ?? null,
};
if (!closed) {
metric.openOpportunityCount += 1;
metric.pipelineRevenue += revenue.totalRevenue;
metric.taxablePipelineRevenue += revenue.taxableRevenue;
metric.nonTaxablePipelineRevenue += revenue.nonTaxableRevenue;
metric.weightedPipelineRevenue +=
revenue.totalRevenue * probabilityRatio;
metric.opportunityBreakdown.pipeline.push(breakdownEntry);
}
const closedDate = opp.closedDate;
if (!closedDate) continue;
const isMtd = closedDate >= monthStart;
const isYtd = closedDate >= yearStart;
if (won) {
if (isMtd) {
metric.winCount.mtd += 1;
metric.wonOpportunityCount.mtd += 1;
metric.closedOpportunityCount.mtd += 1;
metric.closedWonRevenueMtd += revenue.totalRevenue;
metric.opportunityBreakdown.closedWonMtd.push(breakdownEntry);
}
if (isYtd) {
metric.winCount.ytd += 1;
metric.wonOpportunityCount.ytd += 1;
metric.closedOpportunityCount.ytd += 1;
metric.closedWonRevenueYtd += revenue.totalRevenue;
wonDaysSumYtd += daysBetween(
opp.dateBecameLead ?? closedDate,
closedDate,
);
metric.opportunityBreakdown.closedWonYtd.push(breakdownEntry);
}
}
if (!lost) continue;
if (isMtd) {
metric.lossCount.mtd += 1;
metric.lostOpportunityCount.mtd += 1;
metric.closedOpportunityCount.mtd += 1;
metric.opportunityBreakdown.closedLostMtd.push(breakdownEntry);
}
if (!isYtd) continue;
metric.lossCount.ytd += 1;
metric.lostOpportunityCount.ytd += 1;
metric.closedOpportunityCount.ytd += 1;
metric.opportunityBreakdown.closedLostYtd.push(breakdownEntry);
}
metric.assignedOpportunityCount = assigned.length;
metric.avgDaysToClose =
metric.winCount.ytd > 0 ? wonDaysSumYtd / metric.winCount.ytd : 0;
metric.avgOpenDealSize =
metric.openOpportunityCount > 0
? metric.pipelineRevenue / metric.openOpportunityCount
: 0;
metric.avgWonDealSize.mtd =
metric.winCount.mtd > 0
? metric.closedWonRevenueMtd / metric.winCount.mtd
: 0;
metric.avgWonDealSize.ytd =
metric.winCount.ytd > 0
? metric.closedWonRevenueYtd / metric.winCount.ytd
: 0;
const closedMtd = metric.winCount.mtd + metric.lossCount.mtd;
const closedYtd = metric.winCount.ytd + metric.lossCount.ytd;
metric.winRate.mtd =
closedMtd > 0 ? metric.winCount.mtd / closedMtd : 0;
metric.winRate.ytd =
closedYtd > 0 ? metric.winCount.ytd / closedYtd : 0;
metric.lossRate.mtd =
closedMtd > 0 ? metric.lossCount.mtd / closedMtd : 0;
metric.lossRate.ytd =
closedYtd > 0 ? metric.lossCount.ytd / closedYtd : 0;
const totalLookups = metric.cacheHitCount + metric.cacheMissCount;
metric.cacheHitRate =
totalLookups > 0 ? metric.cacheHitCount / totalLookups : 0;
metric.pipelineRevenue = roundCurrency(metric.pipelineRevenue);
metric.closedWonRevenueMtd = roundCurrency(metric.closedWonRevenueMtd);
metric.closedWonRevenueYtd = roundCurrency(metric.closedWonRevenueYtd);
metric.weightedPipelineRevenue = roundCurrency(
metric.weightedPipelineRevenue,
);
metric.taxablePipelineRevenue = roundCurrency(
metric.taxablePipelineRevenue,
);
metric.nonTaxablePipelineRevenue = roundCurrency(
metric.nonTaxablePipelineRevenue,
);
metric.avgDaysToClose = roundCurrency(metric.avgDaysToClose);
metric.avgOpenDealSize = roundCurrency(metric.avgOpenDealSize);
metric.avgWonDealSize.mtd = roundCurrency(metric.avgWonDealSize.mtd);
metric.avgWonDealSize.ytd = roundCurrency(metric.avgWonDealSize.ytd);
metric.winRate.mtd = roundCurrency(metric.winRate.mtd);
metric.winRate.ytd = roundCurrency(metric.winRate.ytd);
metric.lossRate.mtd = roundCurrency(metric.lossRate.mtd);
metric.lossRate.ytd = roundCurrency(metric.lossRate.ytd);
metric.cacheHitRate = roundCurrency(metric.cacheHitRate);
members[identifier] = metric;
if (Object.keys(members).length % 25 === 0) {
log(
`member aggregation progress: aggregated=${Object.keys(members).length}/${activeMembers.length}`,
);
}
}
log(
`member aggregation completed: totalMembers=${Object.keys(members).length}`,
);
const envelope: SalesMetricsCacheEnvelope = {
generatedAt,
activeMemberCount: memberIdentifiers.length,
memberIdentifiers,
members,
};
const pipeline = redis.pipeline();
log("redis write phase started");
pipeline.set(
ALL_MEMBERS_KEY,
JSON.stringify(envelope),
"PX",
METRICS_CACHE_TTL_MS,
);
for (const identifier of Object.keys(members)) {
pipeline.set(
memberKey(identifier),
JSON.stringify(members[identifier]),
"PX",
METRICS_CACHE_TTL_MS,
);
}
await pipeline.exec();
log("redis write phase completed");
const cacheHitCount = Object.values(members).reduce(
(sum, metric) => sum + metric.cacheHitCount,
0,
);
const cacheMissCount = Object.values(members).reduce(
(sum, metric) => sum + metric.cacheMissCount,
0,
);
events.emit("cache:salesMetrics:refresh:completed", {
activeMemberCount: memberIdentifiers.length,
opportunityCount: opportunityRows.length,
memberMetricsWritten: Object.keys(members).length,
cacheHitCount,
cacheMissCount,
durationMs: Date.now() - startedAt,
});
log(
`completed in ${Date.now() - startedAt}ms | activeMembers=${memberIdentifiers.length} opportunities=${opportunityRows.length} memberMetrics=${Object.keys(members).length} cacheHits=${cacheHitCount} cacheMisses=${cacheMissCount}`,
);
} catch (error) {
log(`refresh failed in ${Date.now() - startedAt}ms: ${String(error)}`);
events.emit("cache:salesMetrics:refresh:error", {
error,
durationMs: Date.now() - startedAt,
});
throw error;
}
})().finally(() => {
salesMetricsRefreshInFlight = null;
});
return salesMetricsRefreshInFlight;
}
export async function getSalesOpportunityMetricsAll(): Promise<SalesMetricsCacheEnvelope | null> {
const raw = await redis.get(ALL_MEMBERS_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as SalesMetricsCacheEnvelope;
} catch {
return null;
}
}
export async function getSalesOpportunityMetricsForMember(
identifier: string,
): Promise<MemberSalesMetrics | null> {
const normalized = identifier.trim().toLowerCase();
if (!normalized) return null;
const raw = await redis.get(memberKey(normalized));
if (raw) {
try {
return JSON.parse(raw) as MemberSalesMetrics;
} catch {
return null;
}
}
const all = await getSalesOpportunityMetricsAll();
if (!all) return null;
return all.members[normalized] ?? null;
}
@@ -1,4 +1,5 @@
import { CWOpportunity } from "./opportunity.types";
import { normalizeProbabilityPercent } from "../../sales-utils/normalizeProbability";
export type ProcessedOpportunity = ReturnType<
typeof processOpportunityResponse
@@ -14,7 +15,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
expectedCloseDate: opportunity.expectedCloseDate,
closedDate: opportunity.closedDate,
closedFlag: opportunity.closedFlag,
probability: Number(opportunity.probability?.name) || 0,
probability: normalizeProbabilityPercent(opportunity.probability?.name),
type: opportunity.type
? { id: opportunity.type.id, name: opportunity.type.name }
: null,
@@ -22,11 +22,14 @@ export const refreshOpportunities = async () => {
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
const dbItems = await prisma.opportunity.findMany({
select: { id: true, cwOpportunityId: true, cwLastUpdated: true },
select: {
id: true,
cwOpportunityId: true,
cwLastUpdated: true,
cwDateEntered: true,
},
});
const dbMap = new Map(
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]),
);
const dbMap = new Map(dbItems.map((item) => [item.cwOpportunityId, item]));
// 3. Determine stale / new IDs
const staleIds: number[] = [];
@@ -35,9 +38,15 @@ export const refreshOpportunities = async () => {
const cwLastUpdated = summary._info?.lastUpdated
? new Date(summary._info.lastUpdated)
: null;
const dbLastUpdated = dbMap.get(cwId) ?? null;
const dbItem = dbMap.get(cwId) ?? null;
const dbLastUpdated = dbItem?.cwLastUpdated ?? null;
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
// Treat as stale if never synced, CW has newer data, or cwDateEntered is missing (backfill)
if (
!dbLastUpdated ||
(cwLastUpdated && cwLastUpdated > dbLastUpdated) ||
!dbItem?.cwDateEntered
) {
staleIds.push(cwId);
}
}
+18
View File
@@ -200,6 +200,24 @@ interface EventTypes {
}) => void;
"cache:opportunities:refresh:error": (data: { error: unknown }) => void;
// Sales Metrics Cache Events
"cache:salesMetrics:refresh:started": (data: {
activeMemberCount: number;
opportunityCount: number;
}) => void;
"cache:salesMetrics:refresh:completed": (data: {
activeMemberCount: number;
opportunityCount: number;
memberMetricsWritten: number;
cacheHitCount: number;
cacheMissCount: number;
durationMs: number;
}) => void;
"cache:salesMetrics:refresh:error": (data: {
error: unknown;
durationMs: number;
}) => void;
// ConnectWise User Defined Fields Events
"cw:udf:refresh:started": () => void;
"cw:udf:refresh:completed": (data: { count: number }) => void;
+3 -1
View File
@@ -54,6 +54,7 @@ export interface QuoteData {
contact: CustomerContact;
quote: QuoteDetails;
lineItems: QuoteLineItem[];
taxableSubtotal?: number;
tax: TaxConfig;
salesRep?: SalesRepInfo;
quoteNarrative?: string;
@@ -158,7 +159,8 @@ export async function generateQuote(
(sum, item) => sum + item.qty * item.unitPrice,
0,
);
const taxAmount = subTotal * data.tax.rate;
const taxableSubTotal = Math.max(0, data.taxableSubtotal ?? subTotal);
const taxAmount = taxableSubTotal * data.tax.rate;
const total = subTotal + taxAmount;
const logoDataUrl = loadLogoDataUrl(logoPath);
@@ -0,0 +1,98 @@
import { readFileSync } from "fs";
export interface SalesTaxAddressInput {
line1?: string | null;
line2?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
country?: string | null;
}
interface LocalJurisdiction {
city: string;
local_rate: number;
combined_rate: number;
}
interface StateTaxRecord {
state: string;
abbreviation: string;
state_rate: number;
avg_local_rate: number;
avg_combined_rate: number;
has_local_tax: boolean;
local_jurisdictions?: LocalJurisdiction[];
}
const taxDataPath = new URL("./salesTaxRates.json", import.meta.url);
const parseTaxData = (): StateTaxRecord[] => {
try {
const raw = readFileSync(taxDataPath, "utf-8");
const parsed = JSON.parse(raw) as StateTaxRecord[];
if (!Array.isArray(parsed)) return [];
return parsed;
} catch {
return [];
}
};
const SALES_TAX_DATA = parseTaxData();
const normalizeToken = (value: string | null | undefined): string | null => {
if (!value) return null;
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!normalized) return null;
return normalized;
};
const normalizeState = (state: string | null | undefined): string | null => {
const normalized = normalizeToken(state);
if (!normalized) return null;
const directCode = normalized.toUpperCase();
if (directCode.length === 2) return directCode;
const match = SALES_TAX_DATA.find(
(record) => normalizeToken(record.state) === normalized,
);
if (!match) return null;
return match.abbreviation.toUpperCase();
};
/**
* Compute expected sales tax rate for an address.
* Returns a decimal tax rate (e.g. 0.06 for 6%).
*/
export const getExpectedSalesTaxRate = (
address: SalesTaxAddressInput | null | undefined,
): number => {
const state = normalizeState(address?.state);
if (!state) return 0;
// Business rule: Tennessee remains explicitly hard-coded.
if (state === "TN") return 0.0975;
const stateRecord = SALES_TAX_DATA.find(
(record) => record.abbreviation.toUpperCase() === state,
);
if (!stateRecord) return 0;
const city = normalizeToken(address?.city);
const cityMatch = stateRecord.local_jurisdictions?.find(
(jurisdiction) => normalizeToken(jurisdiction.city) === city,
);
if (cityMatch) return cityMatch.combined_rate;
return stateRecord.avg_combined_rate;
};
@@ -0,0 +1,24 @@
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
/**
* Normalize a probability-like input to a percent scale (0..100).
* Accepts values like "70", "70%", 70, or 0.7.
*/
export const normalizeProbabilityPercent = (value: unknown): number => {
const raw =
typeof value === "string"
? Number.parseFloat(value.replace(/%/g, "").trim())
: Number(value);
if (!Number.isFinite(raw)) return 0;
const scaled = raw <= 1 ? raw * 100 : raw;
return clamp(scaled, 0, 100);
};
/**
* Normalize a probability-like input to a ratio scale (0..1).
*/
export const normalizeProbabilityRatio = (value: unknown): number =>
normalizeProbabilityPercent(value) / 100;
File diff suppressed because it is too large Load Diff
+33
View File
@@ -423,6 +423,33 @@ export const PERMISSION_NODES = {
"src/api/sales/fetchOpportunityTypes.ts",
],
},
{
node: "sales.opportunity.fetch.@me",
description:
"View the personal sales dashboard showing opportunities assigned to the current user (UI-only gate)",
usedIn: ["UI-only (client-side gate)"],
},
{
node: "sales.opportunity.fetch.all",
description:
"View all opportunities across all users (All Opportunities tab and View All button in the sales dashboard UI)",
usedIn: ["UI-only (client-side gate)"],
dependencies: ["sales.opportunity.fetch.many"],
},
{
node: "sales.opportunity.metrics.all",
description:
"Allow `scope=all` on sales opportunity metrics endpoint to read cached metrics for all active members",
usedIn: ["src/api/sales/opportunities/metrics.ts"],
dependencies: ["sales.opportunity.fetch.many"],
},
{
node: "sales.opportunity.metrics.identifier.override",
description:
"Allow `identifier=<cwIdentifier>` override on sales opportunity metrics endpoint for querying another member",
usedIn: ["src/api/sales/opportunities/metrics.ts"],
dependencies: ["sales.opportunity.fetch.many"],
},
{
node: "sales.opportunity.refresh",
description: "Refresh a single opportunity from ConnectWise",
@@ -645,6 +672,12 @@ export const PERMISSION_NODES = {
usedIn: ["src/api/sales/opportunities/[id]/workflow/dispatch.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.isRepresentative",
description:
"Designates the user as a sales representative; used for reporting and filtering purposes.",
usedIn: [],
},
],
},
+21
View File
@@ -200,6 +200,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
],
},
//
// READY TO SEND
//
{
id: 63,
name: "Ready to Send",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "jroberts",
dateEntered: "2026-03-16T02:24:10Z",
_info: {
lastUpdated: "2026-03-16T02:24:10Z",
updatedBy: "jroberts",
},
connectWiseId: "f10c4e36-df20-f111-b2ee-000c29c55070",
optimaEquivalency: [],
},
//
// PENDING SENT
//
+73 -8
View File
@@ -30,7 +30,8 @@
* | QuoteSent | 43 | 03. Quote Sent |
* | ConfirmedQuote | 57 | 04. Confirmed Quote |
* | Active | 58 | 05. Active |
* | PendingSent | 60 | Pending Sent |
* | ReadyToSend | 63 | Ready to Send |
* | PendingSent | 60 | Pending Sent (Deprecated) |
* | PendingRevision | 61 | Pending Revision |
* | PendingWon | 49 | 91. Pending Won |
* | Won | 29 | 95. Won |
@@ -65,6 +66,7 @@ export const OpportunityStatus = {
QuoteSent: 43,
ConfirmedQuote: 57,
Active: 58,
ReadyToSend: 63,
PendingSent: 60,
PendingRevision: 61,
PendingWon: 49,
@@ -180,19 +182,25 @@ const ALLOWED_TRANSITIONS: Record<number, Set<number>> = {
[OpportunityStatus.PendingNew]: new Set([OpportunityStatus.New]),
[OpportunityStatus.New]: new Set([
OpportunityStatus.ReadyToSend,
OpportunityStatus.InternalReview,
OpportunityStatus.QuoteSent,
OpportunityStatus.Canceled,
]),
[OpportunityStatus.InternalReview]: new Set([
OpportunityStatus.PendingSent,
OpportunityStatus.ReadyToSend,
OpportunityStatus.PendingRevision,
OpportunityStatus.QuoteSent, // reviewer manually sends
OpportunityStatus.Canceled,
]),
[OpportunityStatus.PendingSent]: new Set([OpportunityStatus.QuoteSent]),
[OpportunityStatus.ReadyToSend]: new Set([OpportunityStatus.QuoteSent]),
[OpportunityStatus.PendingSent]: new Set([
OpportunityStatus.ReadyToSend,
OpportunityStatus.QuoteSent,
]),
[OpportunityStatus.PendingRevision]: new Set([OpportunityStatus.Active]),
@@ -214,6 +222,7 @@ const ALLOWED_TRANSITIONS: Record<number, Set<number>> = {
]),
[OpportunityStatus.Active]: new Set([
OpportunityStatus.ReadyToSend,
OpportunityStatus.QuoteSent,
OpportunityStatus.InternalReview,
OpportunityStatus.Canceled,
@@ -275,7 +284,7 @@ export interface ReviewDecisionPayload extends BaseActionPayload {
decision: "approve" | "reject" | "send" | "cancel";
}
/** Transition from PendingSent → QuoteSent. */
/** Transition from ReadyToSend/PendingSent(deprecated) → QuoteSent. */
export interface SendQuotePayload extends BaseActionPayload {
/**
* If true, marks sent AND confirmed simultaneously.
@@ -313,6 +322,9 @@ export interface SendQuotePayload extends BaseActionPayload {
needsRevision?: boolean;
}
/** Mark opportunity as ready to send quote from send-capable statuses. */
export interface MarkReadyToSendPayload extends BaseActionPayload {}
/** Confirm receipt of a quote. */
export interface ConfirmQuotePayload extends BaseActionPayload {}
@@ -351,6 +363,7 @@ export type WorkflowAction =
| { action: "acceptNew"; payload: AcceptNewPayload }
| { action: "requestReview"; payload: RequestReviewPayload }
| { action: "reviewDecision"; payload: ReviewDecisionPayload }
| { action: "markReadyToSend"; payload: MarkReadyToSendPayload }
| { action: "sendQuote"; payload: SendQuotePayload }
| { action: "confirmQuote"; payload: ConfirmQuotePayload }
| { action: "finalize"; payload: FinalizePayload }
@@ -728,7 +741,7 @@ export async function transitionToInternalReview(
}
/**
* InternalReview PendingSent | PendingRevision | QuoteSent | Canceled
* InternalReview ReadyToSend | PendingRevision | QuoteSent | Canceled
*
* Reviewer makes a decision on an opportunity in InternalReview.
*/
@@ -753,9 +766,9 @@ export async function handleReviewDecision(
const activities: ActivityController[] = [];
switch (payload.decision) {
// ── Approve → PendingSent ──────────────────────────────────────────
// ── Approve → ReadyToSend ──────────────────────────────────────────
case "approve": {
const targetStatus = OpportunityStatus.PendingSent;
const targetStatus = OpportunityStatus.ReadyToSend;
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
if (transErr) return fail(transErr, currentStatus);
@@ -901,7 +914,7 @@ export async function handleReviewDecision(
}
/**
* PendingSent QuoteSent (and its compound transitions)
* ReadyToSend/PendingSent(deprecated) QuoteSent (and its compound transitions)
*
* Also handles New QuoteSent (direct send, skipping review) and
* Active QuoteSent (re-send after revision).
@@ -1172,6 +1185,54 @@ export async function transitionToQuoteSent(
return ok(currentStatus, targetStatus, activities);
}
/**
* New/Active/PendingSent(deprecated) ReadyToSend
*
* Allows staging an opportunity as ready without immediately sending.
*/
export async function transitionToReadyToSend(
opportunity: OpportunityController,
user: WorkflowUser,
payload: MarkReadyToSendPayload,
): Promise<WorkflowResult> {
const currentStatus = opportunity.statusCwId;
if (currentStatus == null) return fail("Opportunity has no current status.");
if (!hasPermission(user, WorkflowPermissions.SEND)) {
return fail(
`User lacks the "${WorkflowPermissions.SEND}" permission required to mark an opportunity ready to send.`,
currentStatus,
);
}
const targetStatus = OpportunityStatus.ReadyToSend;
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
if (transErr) return fail(transErr, currentStatus);
const activity = await createWorkflowActivity({
name: `[Workflow] Marked ready to send — ${opportunity.name}`,
opportunityCwId: opportunity.cwOpportunityId,
companyCwId: opportunity.companyCwId,
assignToCwMemberId: user.cwMemberId,
notes: payload.note ?? "Marked ready to send.",
optimaType: OptimaType.OpportunityReview,
});
await syncOpportunityStatus({
opportunityId: opportunity.cwOpportunityId,
statusCwId: targetStatus,
});
await handleTimeEntry(
activity.cwActivityId,
user.cwMemberId,
payload,
payload.note ?? "Marked ready to send.",
);
return ok(currentStatus, targetStatus, [activity]);
}
/**
* QuoteSent ConfirmedQuote
*
@@ -1754,6 +1815,10 @@ export async function processOpportunityAction(
result = await handleReviewDecision(opportunity, user, payload);
break;
case "markReadyToSend":
result = await transitionToReadyToSend(opportunity, user, payload);
break;
case "sendQuote":
result = await transitionToQuoteSent(opportunity, user, payload);
break;