feat: expand sales opportunity workflow and metrics APIs
This commit is contained in:
@@ -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") ?? "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 0–100 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user