2121 lines
70 KiB
TypeScript
2121 lines
70 KiB
TypeScript
/**
|
|
* @module wf.opportunity
|
|
*
|
|
* Opportunity Workflow
|
|
* ====================
|
|
*
|
|
* Central workflow engine for the opportunity lifecycle. All state
|
|
* transitions, follow-up scheduling, cold detection, and CW activity
|
|
* creation are driven through this file.
|
|
*
|
|
* ## Ground rules
|
|
*
|
|
* 1. Every state transition creates a CW activity as the audit trail.
|
|
* 2. Activities carry an `Optima_Type` custom field to tag their type.
|
|
* 3. The opportunity's CW status is the source of truth for current state.
|
|
* 4. The activity history IS the metadata — timestamps, notes, and
|
|
* state changes are all derived from activities.
|
|
* 5. When `timeSpent` is provided on an activity close, a CW time entry
|
|
* is automatically submitted.
|
|
* 6. The opportunity's stage MUST be "Optima" before any workflow
|
|
* action is allowed.
|
|
*
|
|
* ## Statuses (CW IDs)
|
|
*
|
|
* | Enum Key | CW ID | CW Name |
|
|
* |-------------------|-------|------------------------|
|
|
* | PendingNew | 37 | 00. Pending New |
|
|
* | New | 24 | 01. New |
|
|
* | InternalReview | 56 | 02. Internal Review |
|
|
* | QuoteSent | 43 | 03. Quote Sent |
|
|
* | ConfirmedQuote | 57 | 04. Confirmed Quote |
|
|
* | Active | 58 | 05. Active |
|
|
* | ReadyToSend | 63 | Ready to Send |
|
|
* | PendingSent | 60 | Pending Sent (Deprecated) |
|
|
* | PendingRevision | 61 | Pending Revision |
|
|
* | PendingWon | 49 | 91. Pending Won |
|
|
* | Won | 29 | 95. Won |
|
|
* | PendingLost | 50 | 98. Pending Lost |
|
|
* | Lost | 53 | 99. Lost |
|
|
* | Canceled | 59 | Canceled |
|
|
*/
|
|
|
|
import { OpportunityController } from "../controllers/OpportunityController";
|
|
import { ActivityController } from "../controllers/ActivityController";
|
|
import { activityCw } from "../modules/cw-utils/activities/activities";
|
|
import {
|
|
checkColdStatus,
|
|
type ColdCheckResult,
|
|
} from "../modules/algorithms/algo.coldThreshold";
|
|
import {
|
|
submitTimeEntry,
|
|
syncOpportunityStatus,
|
|
} from "../services/cw.opportunityService";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CONSTANTS & ENUMS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Canonical status enum mapping workflow names → CW status IDs.
|
|
*/
|
|
export const OpportunityStatus = {
|
|
PendingNew: 37,
|
|
New: 24,
|
|
InternalReview: 56,
|
|
QuoteSent: 43,
|
|
ConfirmedQuote: 57,
|
|
Active: 58,
|
|
ReadyToSend: 63,
|
|
PendingSent: 60,
|
|
PendingRevision: 61,
|
|
PendingWon: 49,
|
|
Won: 29,
|
|
PendingLost: 50,
|
|
Lost: 53,
|
|
Canceled: 59,
|
|
} as const;
|
|
|
|
export type OpportunityStatusKey = keyof typeof OpportunityStatus;
|
|
export type OpportunityStatusId =
|
|
(typeof OpportunityStatus)[OpportunityStatusKey];
|
|
|
|
/** Reverse lookup: CW ID → workflow key. */
|
|
export const StatusIdToKey: Record<number, OpportunityStatusKey> =
|
|
Object.fromEntries(
|
|
Object.entries(OpportunityStatus).map(([k, v]) => [
|
|
v,
|
|
k as OpportunityStatusKey,
|
|
]),
|
|
) as Record<number, OpportunityStatusKey>;
|
|
|
|
/** Terminal (immutable) statuses. */
|
|
const TERMINAL_STATUSES = new Set<number>([
|
|
OpportunityStatus.Won,
|
|
OpportunityStatus.Lost,
|
|
]);
|
|
|
|
/**
|
|
* CW Activity custom field for Optima_Type.
|
|
*
|
|
* Field ID 45, with list options:
|
|
* 58 = Quote Setup
|
|
* 58 = Opportunity Setup
|
|
* 59 = Quote Sent
|
|
* 60 = Quote Confirmed
|
|
* 61 = Revision
|
|
* 62 = Finalized
|
|
* 63 = Converted
|
|
* 64 = Opportunity Created
|
|
* 65 = Opportunity Review
|
|
* 66 = Quote Generated
|
|
* 67 = Quote Sent & Confirmed
|
|
*/
|
|
export const OptimaType = {
|
|
FIELD_ID: 45,
|
|
OpportunityCreated: "Opportunity Created",
|
|
OpportunitySetup: "Opportunity Setup",
|
|
OpportunityReview: "Opportunity Review",
|
|
QuoteSent: "Quote Sent",
|
|
QuoteConfirmed: "Quote Confirmed",
|
|
QuoteSentConfirmed: "Quote Sent & Confirmed",
|
|
QuoteGenerated: "Quote Generated",
|
|
Revision: "Revision",
|
|
Finalized: "Finalized",
|
|
Converted: "Converted",
|
|
ScheduleEntry: "Schedule Entry",
|
|
} as const;
|
|
|
|
/** CW custom field ID for the QuoteID field on activities. */
|
|
const QUOTE_ID_FIELD_ID = 48;
|
|
|
|
/** CW custom field ID for the Close Date field on activities. */
|
|
const CLOSE_DATE_FIELD_ID = 49;
|
|
|
|
/** CW custom field ID for the Parent Activity field on activities. */
|
|
export const PARENT_ACTIVITY_FIELD_ID = 50;
|
|
|
|
/**
|
|
* Optima_Type values whose activities should remain Open until the
|
|
* next workflow transition closes them automatically.
|
|
*/
|
|
const STAYS_OPEN_TYPES = new Set<OptimaTypeValue>([
|
|
OptimaType.OpportunitySetup,
|
|
OptimaType.OpportunityReview,
|
|
OptimaType.Revision,
|
|
OptimaType.ScheduleEntry,
|
|
]);
|
|
|
|
export type OptimaTypeValue =
|
|
| typeof OptimaType.OpportunityCreated
|
|
| typeof OptimaType.OpportunitySetup
|
|
| typeof OptimaType.OpportunityReview
|
|
| typeof OptimaType.QuoteSent
|
|
| typeof OptimaType.QuoteConfirmed
|
|
| typeof OptimaType.QuoteSentConfirmed
|
|
| typeof OptimaType.QuoteGenerated
|
|
| typeof OptimaType.Revision
|
|
| typeof OptimaType.Finalized
|
|
| typeof OptimaType.Converted
|
|
| typeof OptimaType.ScheduleEntry;
|
|
|
|
/** Permission nodes required by gated transitions. */
|
|
export const WorkflowPermissions = {
|
|
FINALIZE: "sales.opportunity.finalize",
|
|
CANCEL: "sales.opportunity.cancel",
|
|
REVIEW: "sales.opportunity.review",
|
|
SEND: "sales.opportunity.send",
|
|
REOPEN: "sales.opportunity.reopen",
|
|
WIN: "sales.opportunity.win",
|
|
LOSE: "sales.opportunity.lose",
|
|
} as const;
|
|
|
|
/** Required Optima stage name. */
|
|
const REQUIRED_STAGE = "Optima";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// TRANSITION MAP
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Defines which target statuses are reachable from each source status.
|
|
*
|
|
* This map covers DIRECT transitions only. Some transitions are
|
|
* compound (e.g. QuoteSent with `won: true` goes through Won/PendingWon),
|
|
* which are handled inside the individual transition functions.
|
|
*/
|
|
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.ReadyToSend,
|
|
OpportunityStatus.PendingRevision,
|
|
OpportunityStatus.QuoteSent, // reviewer manually sends
|
|
OpportunityStatus.Canceled,
|
|
]),
|
|
|
|
[OpportunityStatus.ReadyToSend]: new Set([OpportunityStatus.QuoteSent]),
|
|
|
|
[OpportunityStatus.PendingSent]: new Set([
|
|
OpportunityStatus.ReadyToSend,
|
|
OpportunityStatus.QuoteSent,
|
|
]),
|
|
|
|
[OpportunityStatus.PendingRevision]: new Set([OpportunityStatus.Active]),
|
|
|
|
[OpportunityStatus.QuoteSent]: new Set([
|
|
OpportunityStatus.ConfirmedQuote,
|
|
OpportunityStatus.Won,
|
|
OpportunityStatus.PendingWon,
|
|
OpportunityStatus.PendingLost,
|
|
OpportunityStatus.Active,
|
|
OpportunityStatus.PendingRevision, // needs revision
|
|
OpportunityStatus.InternalReview, // cold automation only
|
|
]),
|
|
|
|
[OpportunityStatus.ConfirmedQuote]: new Set([
|
|
OpportunityStatus.Won,
|
|
OpportunityStatus.PendingWon,
|
|
OpportunityStatus.PendingLost,
|
|
OpportunityStatus.Active,
|
|
OpportunityStatus.InternalReview, // cold automation only
|
|
OpportunityStatus.PendingRevision, // send back for revision
|
|
]),
|
|
|
|
[OpportunityStatus.Active]: new Set([
|
|
OpportunityStatus.ReadyToSend,
|
|
OpportunityStatus.QuoteSent,
|
|
OpportunityStatus.InternalReview,
|
|
OpportunityStatus.Canceled,
|
|
]),
|
|
|
|
[OpportunityStatus.PendingWon]: new Set([
|
|
OpportunityStatus.Won,
|
|
OpportunityStatus.Active, // admin revision
|
|
]),
|
|
|
|
[OpportunityStatus.PendingLost]: new Set([
|
|
OpportunityStatus.Lost,
|
|
OpportunityStatus.Active, // resurrection
|
|
]),
|
|
|
|
[OpportunityStatus.Won]: new Set(), // terminal
|
|
[OpportunityStatus.Lost]: new Set(), // terminal
|
|
|
|
[OpportunityStatus.Canceled]: new Set([
|
|
OpportunityStatus.Active, // re-open
|
|
]),
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// TYPES
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/** User context passed into every workflow action. */
|
|
export interface WorkflowUser {
|
|
/** Internal user ID. */
|
|
id: string;
|
|
/** CW member ID for activity assignment. */
|
|
cwMemberId: number;
|
|
/** Resolved permission node strings the user holds. */
|
|
permissions: string[];
|
|
}
|
|
|
|
/** Base payload fields common to all actions. */
|
|
interface BaseActionPayload {
|
|
/** Required note for state-change activities. */
|
|
note?: string;
|
|
/** ISO-8601 datetime when work started. Both timeStarted and timeEnded must be provided to submit a time entry. */
|
|
timeStarted?: string;
|
|
/** ISO-8601 datetime when work ended. Both timeStarted and timeEnded must be provided to submit a time entry. */
|
|
timeEnded?: string;
|
|
}
|
|
|
|
/** Transition to New. */
|
|
export interface AcceptNewPayload extends BaseActionPayload {}
|
|
|
|
/** Transition to InternalReview. */
|
|
export interface RequestReviewPayload extends BaseActionPayload {
|
|
note: string; // required
|
|
}
|
|
|
|
/** Review decision payload (approve / reject / send / cancel). */
|
|
export interface ReviewDecisionPayload extends BaseActionPayload {
|
|
note: string; // required
|
|
decision: "approve" | "reject" | "send" | "cancel";
|
|
}
|
|
|
|
/** Transition from ReadyToSend/PendingSent(deprecated) → QuoteSent. */
|
|
export interface SendQuotePayload extends BaseActionPayload {
|
|
/**
|
|
* If true, marks sent AND confirmed simultaneously.
|
|
* Skips receipt confirmation follow-up. Transitions to ConfirmedQuote.
|
|
*/
|
|
quoteConfirmed?: boolean;
|
|
|
|
/**
|
|
* Quote was presented and won immediately.
|
|
* Without finalize: transitions to PendingWon.
|
|
* With finalize (requires FINALIZE perm): transitions directly to Won.
|
|
* Satisfies QuoteSent and ConfirmedQuote simultaneously.
|
|
*/
|
|
won?: boolean;
|
|
|
|
/**
|
|
* Quote rejected immediately upon presentation.
|
|
* Without finalize: transitions to PendingLost.
|
|
* With finalize (requires FINALIZE perm): transitions directly to Lost.
|
|
*/
|
|
lost?: boolean;
|
|
|
|
/**
|
|
* Explicitly finalize the outcome (skip pending state).
|
|
* Requires `sales.opportunity.finalize` permission.
|
|
* Used with `won` or `lost` to go directly to Won/Lost
|
|
* instead of PendingWon/PendingLost.
|
|
*/
|
|
finalize?: boolean;
|
|
|
|
/**
|
|
* Quote needs revision.
|
|
* Creates a revision activity and transitions to PendingRevision.
|
|
*/
|
|
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 {}
|
|
|
|
/** Mark opportunity as won or lost. */
|
|
export interface FinalizePayload extends BaseActionPayload {
|
|
note: string; // required
|
|
outcome: "won" | "lost";
|
|
}
|
|
|
|
/** Resurrect from PendingWon / PendingLost → Active. */
|
|
export interface ResurrectPayload extends BaseActionPayload {
|
|
note: string; // required
|
|
}
|
|
|
|
/** Begin revision from PendingRevision → Active. */
|
|
export interface BeginRevisionPayload extends BaseActionPayload {}
|
|
|
|
/** Send back for revision from ConfirmedQuote → PendingRevision. */
|
|
export interface SendBackForRevisionPayload extends BaseActionPayload {
|
|
note: string; // required
|
|
}
|
|
|
|
/** Create a Schedule Entry activity (no status change). */
|
|
export interface CreateScheduleEntryPayload extends BaseActionPayload {
|
|
/** Activity type value: Follow-Up, Appointment, or Admin. */
|
|
activityTypeValue: "Follow-Up" | "Appointment" | "Admin";
|
|
/** ISO-8601 due date. */
|
|
dueDate?: string;
|
|
/** ISO-8601 start time. */
|
|
startTime?: string;
|
|
/** ISO-8601 end time. */
|
|
endTime?: string;
|
|
/** Optional notes for the schedule entry. */
|
|
note?: string;
|
|
}
|
|
|
|
/** Re-send from Active → QuoteSent. */
|
|
export interface ResendQuotePayload extends SendQuotePayload {}
|
|
|
|
/** Cancel payload. */
|
|
export interface CancelPayload extends BaseActionPayload {
|
|
note: string; // required
|
|
}
|
|
|
|
/** Re-open from Canceled → Active. */
|
|
export interface ReopenPayload extends BaseActionPayload {
|
|
note: string; // required
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Action discriminator
|
|
// -----------------------------------------------------------------------
|
|
|
|
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 }
|
|
| { action: "resurrect"; payload: ResurrectPayload }
|
|
| { action: "beginRevision"; payload: BeginRevisionPayload }
|
|
| { action: "resendQuote"; payload: ResendQuotePayload }
|
|
| { action: "cancel"; payload: CancelPayload }
|
|
| { action: "reopen"; payload: ReopenPayload }
|
|
| { action: "sendBackForRevision"; payload: SendBackForRevisionPayload }
|
|
| { action: "createScheduleEntry"; payload: CreateScheduleEntryPayload };
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Result
|
|
// -----------------------------------------------------------------------
|
|
|
|
export interface WorkflowResult {
|
|
success: boolean;
|
|
/** Previous CW status ID, null when the status did not change. */
|
|
previousStatusId: number | null;
|
|
/** New CW status ID after the transition, null on failure. */
|
|
newStatusId: number | null;
|
|
/** Human-readable status keys. */
|
|
previousStatus: OpportunityStatusKey | null;
|
|
newStatus: OpportunityStatusKey | null;
|
|
/** Activities created during the transition. */
|
|
activitiesCreated: ActivityController[];
|
|
/** Cold-check result, when applicable. */
|
|
coldCheck: ColdCheckResult | null;
|
|
/** Error message on failure. */
|
|
error: string | null;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// HELPERS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function hasPermission(user: WorkflowUser, node: string): boolean {
|
|
return user.permissions.includes("*") || user.permissions.includes(node);
|
|
}
|
|
|
|
function fail(
|
|
message: string,
|
|
currentStatusId?: number | null,
|
|
): WorkflowResult {
|
|
return {
|
|
success: false,
|
|
previousStatusId: currentStatusId ?? null,
|
|
newStatusId: null,
|
|
previousStatus:
|
|
currentStatusId != null ? (StatusIdToKey[currentStatusId] ?? null) : null,
|
|
newStatus: null,
|
|
activitiesCreated: [],
|
|
coldCheck: null,
|
|
error: message,
|
|
};
|
|
}
|
|
|
|
function ok(
|
|
previousStatusId: number,
|
|
newStatusId: number,
|
|
activities: ActivityController[],
|
|
coldCheck: ColdCheckResult | null = null,
|
|
): WorkflowResult {
|
|
return {
|
|
success: true,
|
|
previousStatusId,
|
|
newStatusId,
|
|
previousStatus: StatusIdToKey[previousStatusId] ?? null,
|
|
newStatus: StatusIdToKey[newStatusId] ?? null,
|
|
activitiesCreated: activities,
|
|
coldCheck,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the `customFields` array for a CW activity with Optima_Type set,
|
|
* and optionally a QuoteID, CloseDate, or ParentActivity.
|
|
*/
|
|
function buildCustomFields(
|
|
optimaType: OptimaTypeValue,
|
|
opts?: { quoteId?: string; closeDate?: string; parentActivityCwId?: number },
|
|
) {
|
|
const fields: any[] = [
|
|
{
|
|
id: OptimaType.FIELD_ID,
|
|
caption: "Optima_Type",
|
|
type: "Text",
|
|
entryMethod: "List",
|
|
numberOfDecimals: 0,
|
|
value: optimaType,
|
|
},
|
|
];
|
|
|
|
if (opts?.quoteId) {
|
|
fields.push({
|
|
id: QUOTE_ID_FIELD_ID,
|
|
caption: "QuoteID",
|
|
type: "Text",
|
|
entryMethod: "EntryField",
|
|
numberOfDecimals: 0,
|
|
value: opts.quoteId,
|
|
});
|
|
}
|
|
|
|
if (opts?.closeDate) {
|
|
fields.push({
|
|
id: CLOSE_DATE_FIELD_ID,
|
|
caption: "Close Date",
|
|
type: "Text",
|
|
entryMethod: "Date",
|
|
numberOfDecimals: 0,
|
|
value: opts.closeDate,
|
|
});
|
|
}
|
|
|
|
if (opts?.parentActivityCwId != null) {
|
|
fields.push({
|
|
id: PARENT_ACTIVITY_FIELD_ID,
|
|
caption: "Parent_Activity",
|
|
type: "Text",
|
|
entryMethod: "EntryField",
|
|
numberOfDecimals: 0,
|
|
value: String(opts.parentActivityCwId),
|
|
});
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Create a CW activity for a workflow transition.
|
|
*/
|
|
export async function createWorkflowActivity(opts: {
|
|
name: string;
|
|
opportunityCwId: number;
|
|
companyCwId: number | null;
|
|
assignToCwMemberId: number;
|
|
notes: string;
|
|
optimaType: OptimaTypeValue;
|
|
quoteId?: string;
|
|
dateStart?: string;
|
|
dateEnd?: string;
|
|
parentActivityCwId?: number | null;
|
|
}): Promise<ActivityController> {
|
|
const shouldStayOpen = STAYS_OPEN_TYPES.has(opts.optimaType);
|
|
|
|
const activity = await ActivityController.create({
|
|
name: opts.name,
|
|
type: { id: 3 }, // HistoricEntry
|
|
opportunity: { id: opts.opportunityCwId },
|
|
...(opts.companyCwId ? { company: { id: opts.companyCwId } } : {}),
|
|
assignTo: { id: opts.assignToCwMemberId },
|
|
...(shouldStayOpen ? {} : { status: { id: 2 } }), // Closed unless stays-open
|
|
notes: opts.notes,
|
|
});
|
|
|
|
// Set custom fields (Optima_Type + optional QuoteID + closeDate for closed activities).
|
|
// For non-stays-open types, also re-assert Closed status.
|
|
const now = shouldStayOpen ? undefined : new Date().toISOString();
|
|
const patchOps: any[] = [
|
|
{
|
|
op: "replace",
|
|
path: "customFields",
|
|
value: buildCustomFields(opts.optimaType, {
|
|
quoteId: opts.quoteId,
|
|
closeDate: now,
|
|
parentActivityCwId: opts.parentActivityCwId ?? undefined,
|
|
}),
|
|
},
|
|
];
|
|
|
|
if (!shouldStayOpen) {
|
|
patchOps.push({
|
|
op: "replace",
|
|
path: "status",
|
|
value: { id: 2 }, // Closed
|
|
});
|
|
}
|
|
|
|
const patched = await activity.update(patchOps);
|
|
return patched;
|
|
}
|
|
|
|
/**
|
|
* Resolve the parent activity CW ID for a newly generated quote activity.
|
|
*
|
|
* Finds the most recently created workflow activity (by CW ID descending) for
|
|
* the opportunity, excluding QuoteGenerated and ScheduleEntry types. This
|
|
* ensures the quote activity is nested under the current workflow state's
|
|
* activity regardless of whether that activity is open or closed.
|
|
*/
|
|
export async function resolveQuoteParentActivityCwId(
|
|
opportunityCwId: number,
|
|
): Promise<number | null> {
|
|
try {
|
|
const existingActivities = await activityCw.fetchByOpportunityDirect(opportunityCwId);
|
|
// Sort descending by CW id so the most recently created comes first
|
|
const sorted = [...existingActivities].sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
|
|
for (const raw of sorted) {
|
|
const optimaField = raw.customFields?.find(
|
|
(f: any) => f.id === OptimaType.FIELD_ID,
|
|
);
|
|
if (!optimaField?.value) continue;
|
|
// Skip QuoteGenerated and ScheduleEntry — these should not be parents
|
|
if (optimaField.value === OptimaType.QuoteGenerated) continue;
|
|
if (optimaField.value === OptimaType.ScheduleEntry) continue;
|
|
return raw.id;
|
|
}
|
|
return null;
|
|
} catch (err) {
|
|
console.warn(`[Workflow:QuoteParent] Could not resolve parent activity: ${err}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle optional time entry: submit to CW if timeStart and timeEnd are provided.
|
|
*/
|
|
async function handleTimeEntry(
|
|
activityCwId: number | undefined,
|
|
cwMemberId: number,
|
|
payload: BaseActionPayload,
|
|
notes: string,
|
|
): Promise<void> {
|
|
console.log(
|
|
`[Workflow:TimeEntry] Called — activityCwId=${activityCwId}, cwMemberId=${cwMemberId}, timeStarted=${payload.timeStarted ?? "MISSING"}, timeEnded=${payload.timeEnded ?? "MISSING"}`,
|
|
);
|
|
|
|
if (activityCwId == null) {
|
|
console.warn(
|
|
`[Workflow:TimeEntry] SKIPPED — activityCwId is ${activityCwId} (activity was not created or has no CW ID)`,
|
|
);
|
|
return;
|
|
}
|
|
if (!payload.timeStarted || !payload.timeEnded) {
|
|
console.warn(
|
|
`[Workflow:TimeEntry] SKIPPED — timeStarted or timeEnded not provided (timeStarted=${payload.timeStarted ?? "undefined"}, timeEnded=${payload.timeEnded ?? "undefined"})`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
`[Workflow:TimeEntry] Submitting — activity=${activityCwId}, member=${cwMemberId}, start=${payload.timeStarted}, end=${payload.timeEnded}, notes="${notes}"`,
|
|
);
|
|
try {
|
|
const result = await submitTimeEntry({
|
|
activityId: activityCwId,
|
|
cwMemberId,
|
|
timeStart: payload.timeStarted,
|
|
timeEnd: payload.timeEnded,
|
|
notes,
|
|
});
|
|
if (result.success) {
|
|
console.log(
|
|
`[Workflow:TimeEntry] SUCCESS — cwTimeEntryId=${result.cwTimeEntryId}, message="${result.message}"`,
|
|
);
|
|
} else {
|
|
console.error(`[Workflow:TimeEntry] FAILED — ${result.message}`);
|
|
}
|
|
} catch (err: any) {
|
|
console.error(
|
|
`[Workflow:TimeEntry] EXCEPTION —`,
|
|
err?.response?.data ?? err?.message ?? err,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// GUARDS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Top-level guard: opportunity stage must be "Optima".
|
|
*/
|
|
function assertOptimaStage(opportunity: OpportunityController): string | null {
|
|
if (opportunity.stageName !== REQUIRED_STAGE) {
|
|
return `Workflow actions require the opportunity stage to be "${REQUIRED_STAGE}". Current stage: "${opportunity.stageName ?? "(none)"}"`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Guard: opportunity must not be in a terminal status.
|
|
*/
|
|
function assertNotTerminal(statusCwId: number | null): string | null {
|
|
if (statusCwId != null && TERMINAL_STATUSES.has(statusCwId)) {
|
|
const key = StatusIdToKey[statusCwId] ?? "Unknown";
|
|
return `Opportunity is in terminal status "${key}" and cannot be modified.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Guard: transition must be allowed by the transition map.
|
|
*/
|
|
function assertTransitionAllowed(
|
|
fromStatusId: number,
|
|
toStatusId: number,
|
|
): string | null {
|
|
const allowed = ALLOWED_TRANSITIONS[fromStatusId];
|
|
if (!allowed || !allowed.has(toStatusId)) {
|
|
const from = StatusIdToKey[fromStatusId] ?? String(fromStatusId);
|
|
const to = StatusIdToKey[toStatusId] ?? String(toStatusId);
|
|
return `Transition from "${from}" to "${to}" is not allowed.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Guard: note must be non-empty.
|
|
*/
|
|
function assertNotePresent(note: string | undefined): string | null {
|
|
if (!note || note.trim().length === 0) {
|
|
return "A non-empty note is required for this action.";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// TRANSITION FUNCTIONS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* PendingNew → New
|
|
*
|
|
* Accept/setup an opportunity.
|
|
*/
|
|
export async function transitionToNew(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: AcceptNewPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId ?? OpportunityStatus.PendingNew;
|
|
const targetStatus = OpportunityStatus.New;
|
|
|
|
const err = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (err) return fail(err, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
// Create setup activity
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Opportunity accepted — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note ?? "Opportunity accepted and moved to New.",
|
|
optimaType: OptimaType.OpportunitySetup,
|
|
});
|
|
activities.push(activity);
|
|
|
|
// Sync status to CW
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note ?? "Opportunity accepted.",
|
|
);
|
|
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* Any pre-confirmed status → InternalReview
|
|
*
|
|
* Manually request internal review. Requires a note.
|
|
*/
|
|
export async function transitionToInternalReview(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: RequestReviewPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const targetStatus = OpportunityStatus.InternalReview;
|
|
|
|
const noteErr = assertNotePresent(payload.note);
|
|
if (noteErr) return fail(noteErr, currentStatus);
|
|
|
|
if (!hasPermission(user, WorkflowPermissions.REVIEW)) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.REVIEW}" permission required to submit an opportunity for internal review.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Sent to Internal Review — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.OpportunityReview,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* InternalReview → ReadyToSend | PendingRevision | QuoteSent | Canceled
|
|
*
|
|
* Reviewer makes a decision on an opportunity in InternalReview.
|
|
*/
|
|
export async function handleReviewDecision(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: ReviewDecisionPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
if (currentStatus !== OpportunityStatus.InternalReview) {
|
|
return fail(
|
|
`Review decisions can only be made when the opportunity is in InternalReview. Current status: "${StatusIdToKey[currentStatus] ?? "Unknown"}"`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const noteErr = assertNotePresent(payload.note);
|
|
if (noteErr) return fail(noteErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
switch (payload.decision) {
|
|
// ── Approve → ReadyToSend ──────────────────────────────────────────
|
|
case "approve": {
|
|
const targetStatus = OpportunityStatus.ReadyToSend;
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Review approved — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.OpportunityReview,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
// ── Reject → PendingRevision ──────────────────────────────────────
|
|
case "reject": {
|
|
const targetStatus = OpportunityStatus.PendingRevision;
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Review rejected — revision required — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.Revision,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
// ── Send directly (reviewer manually sends) ──────────────────────
|
|
case "send": {
|
|
const targetStatus = OpportunityStatus.QuoteSent;
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
// Create approved activity
|
|
const approvedActivity = await createWorkflowActivity({
|
|
name: `[Workflow] Review approved — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: `Review approved: ${payload.note}`,
|
|
optimaType: OptimaType.OpportunityReview,
|
|
});
|
|
activities.push(approvedActivity);
|
|
|
|
// Create quote-sent activity
|
|
const sentActivity = await createWorkflowActivity({
|
|
name: `[Workflow] Quote sent (by reviewer) — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: `Quote sent by reviewer: ${payload.note}`,
|
|
optimaType: OptimaType.QuoteSent,
|
|
});
|
|
activities.push(sentActivity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
// ── Cancel from review ────────────────────────────────────────────
|
|
case "cancel": {
|
|
const targetStatus = OpportunityStatus.Canceled;
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
if (!hasPermission(user, WorkflowPermissions.CANCEL)) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.CANCEL}" permission required to cancel an opportunity.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Canceled from review — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.Finalized,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
default:
|
|
return fail(
|
|
`Unknown review decision: "${(payload as any).decision}"`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ReadyToSend/PendingSent(deprecated) → QuoteSent (and its compound transitions)
|
|
*
|
|
* Also handles New → QuoteSent (direct send, skipping review) and
|
|
* Active → QuoteSent (re-send after revision).
|
|
*
|
|
* Payload flags:
|
|
* quoteConfirmed — sent + confirmed simultaneously → ConfirmedQuote
|
|
* won — presented and won → Won/PendingWon
|
|
* lost — rejected immediately → PendingLost
|
|
* needsRevision — needs revision → Active
|
|
*/
|
|
export async function transitionToQuoteSent(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: SendQuotePayload,
|
|
): 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 send a quote.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
// Validate source status allows transition to QuoteSent
|
|
const transErr = assertTransitionAllowed(
|
|
currentStatus,
|
|
OpportunityStatus.QuoteSent,
|
|
);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
const now = new Date().toISOString();
|
|
|
|
// ── won flag: presented and won immediately ─────────────────────────
|
|
if (payload.won) {
|
|
if (!hasPermission(user, WorkflowPermissions.WIN)) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.WIN}" permission required to mark an opportunity as won.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const wantsFinalize = !!payload.finalize;
|
|
const canFinalize =
|
|
wantsFinalize && hasPermission(user, WorkflowPermissions.FINALIZE);
|
|
|
|
if (wantsFinalize && !canFinalize) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const targetStatus = canFinalize
|
|
? OpportunityStatus.Won
|
|
: OpportunityStatus.PendingWon;
|
|
|
|
// Combined Sent & Confirmed activity
|
|
const sentConfirmedActivity = await createWorkflowActivity({
|
|
name: `[Workflow] Quote sent & confirmed — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes:
|
|
`Quote sent and confirmed simultaneously (immediate win). ${payload.note ?? ""}`.trim(),
|
|
optimaType: OptimaType.QuoteSentConfirmed,
|
|
});
|
|
activities.push(sentConfirmedActivity);
|
|
|
|
// Won/PendingWon activity
|
|
const outcomeType = canFinalize
|
|
? OptimaType.Converted
|
|
: OptimaType.Finalized;
|
|
const wonActivity = await createWorkflowActivity({
|
|
name: `[Workflow] ${canFinalize ? "Won" : "Pending Won"} — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes:
|
|
payload.note ?? `Opportunity ${canFinalize ? "won" : "pending won"}.`,
|
|
optimaType: outcomeType,
|
|
});
|
|
activities.push(wonActivity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note ?? "Immediate win.",
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
// ── lost flag: rejected immediately ─────────────────────────────────
|
|
if (payload.lost) {
|
|
if (!hasPermission(user, WorkflowPermissions.LOSE)) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.LOSE}" permission required to mark an opportunity as lost.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const wantsFinalize = !!payload.finalize;
|
|
const canFinalize =
|
|
wantsFinalize && hasPermission(user, WorkflowPermissions.FINALIZE);
|
|
|
|
if (wantsFinalize && !canFinalize) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const targetStatus = canFinalize
|
|
? OpportunityStatus.Lost
|
|
: OpportunityStatus.PendingLost;
|
|
|
|
const wasAlsoConfirmed = !!payload.quoteConfirmed;
|
|
|
|
const sentActivity = await createWorkflowActivity({
|
|
name: wasAlsoConfirmed
|
|
? `[Workflow] Quote sent & confirmed — ${opportunity.name}`
|
|
: `[Workflow] Quote sent — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: wasAlsoConfirmed
|
|
? `Quote sent and confirmed, but rejected. ${payload.note ?? ""}`.trim()
|
|
: `Quote sent. ${payload.note ?? ""}`.trim(),
|
|
optimaType: wasAlsoConfirmed
|
|
? OptimaType.QuoteSentConfirmed
|
|
: OptimaType.QuoteSent,
|
|
});
|
|
activities.push(sentActivity);
|
|
|
|
const lostActivity = await createWorkflowActivity({
|
|
name: canFinalize
|
|
? `[Workflow] Lost (immediate rejection) — ${opportunity.name}`
|
|
: `[Workflow] Pending Lost (immediate rejection) — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note ?? "Quote rejected immediately upon presentation.",
|
|
optimaType: canFinalize ? OptimaType.Converted : OptimaType.Finalized,
|
|
});
|
|
activities.push(lostActivity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note ?? "Immediate rejection.",
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
// ── needsRevision flag → Active ──────────────────────────────────────
|
|
if (payload.needsRevision) {
|
|
const targetStatus = OpportunityStatus.Active;
|
|
|
|
const wasAlsoConfirmed = !!payload.quoteConfirmed;
|
|
|
|
const sentActivity = await createWorkflowActivity({
|
|
name: wasAlsoConfirmed
|
|
? `[Workflow] Quote sent & confirmed — ${opportunity.name}`
|
|
: `[Workflow] Quote sent — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: wasAlsoConfirmed
|
|
? `Quote sent and confirmed, but revision needed. ${payload.note ?? ""}`.trim()
|
|
: `Quote sent. ${payload.note ?? ""}`.trim(),
|
|
optimaType: wasAlsoConfirmed
|
|
? OptimaType.QuoteSentConfirmed
|
|
: OptimaType.QuoteSent,
|
|
});
|
|
activities.push(sentActivity);
|
|
|
|
const revisionActivity = await createWorkflowActivity({
|
|
name: `[Workflow] Revision needed — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note ?? "Revision requested after quote presentation.",
|
|
optimaType: OptimaType.Revision,
|
|
});
|
|
activities.push(revisionActivity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note ?? "Needs revision.",
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
// ── quoteConfirmed flag → ConfirmedQuote ─────────────────────────────
|
|
if (payload.quoteConfirmed) {
|
|
const targetStatus = OpportunityStatus.ConfirmedQuote;
|
|
|
|
const sentConfirmedActivity = await createWorkflowActivity({
|
|
name: `[Workflow] Quote sent & confirmed — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes:
|
|
`Quote sent and confirmed simultaneously. ${payload.note ?? ""}`.trim(),
|
|
optimaType: OptimaType.QuoteSentConfirmed,
|
|
});
|
|
activities.push(sentConfirmedActivity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note ?? "Quote sent and confirmed.",
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
// ── Default: plain QuoteSent (no flags) ──────────────────────────────
|
|
const targetStatus = OpportunityStatus.QuoteSent;
|
|
|
|
const sentActivity = await createWorkflowActivity({
|
|
name: `[Workflow] Quote sent — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: `Quote sent. ${payload.note ?? ""}`.trim(),
|
|
optimaType: OptimaType.QuoteSent,
|
|
});
|
|
activities.push(sentActivity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note ?? "Quote sent.",
|
|
);
|
|
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
|
|
*
|
|
* Customer has acknowledged receipt of the quote.
|
|
*/
|
|
export async function transitionToConfirmedQuote(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: ConfirmQuotePayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const targetStatus = OpportunityStatus.ConfirmedQuote;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Quote confirmed — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note ?? "Customer confirmed receipt of quote.",
|
|
optimaType: OptimaType.QuoteConfirmed,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note ?? "Quote confirmed.",
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* PendingWon → Won OR PendingLost → Lost
|
|
*
|
|
* Finalize from a pending state. Requires `sales.opportunity.finalize`.
|
|
*
|
|
* Also used for direct Won/Lost from QuoteSent/ConfirmedQuote when
|
|
* the user holds the finalize permission (called internally).
|
|
*/
|
|
export async function finalizeOpportunity(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: FinalizePayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const noteErr = assertNotePresent(payload.note);
|
|
if (noteErr) return fail(noteErr, currentStatus);
|
|
|
|
const requiredPerm =
|
|
payload.outcome === "won"
|
|
? WorkflowPermissions.WIN
|
|
: WorkflowPermissions.LOSE;
|
|
if (!hasPermission(user, requiredPerm)) {
|
|
return fail(
|
|
`User lacks the "${requiredPerm}" permission required to mark an opportunity as ${payload.outcome}.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
if (!hasPermission(user, WorkflowPermissions.FINALIZE)) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const targetStatus =
|
|
payload.outcome === "won" ? OpportunityStatus.Won : OpportunityStatus.Lost;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
const optimaType =
|
|
payload.outcome === "won" ? OptimaType.Converted : OptimaType.Finalized;
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] ${payload.outcome === "won" ? "Won" : "Lost"} — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* ConfirmedQuote/QuoteSent → PendingWon | PendingLost
|
|
*
|
|
* When the user DOES NOT have finalize permission, win/lose actions
|
|
* route to the pending variant.
|
|
*/
|
|
export async function transitionToPending(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: FinalizePayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const noteErr = assertNotePresent(payload.note);
|
|
if (noteErr) return fail(noteErr, currentStatus);
|
|
|
|
const requiredPerm =
|
|
payload.outcome === "won"
|
|
? WorkflowPermissions.WIN
|
|
: WorkflowPermissions.LOSE;
|
|
if (!hasPermission(user, requiredPerm)) {
|
|
return fail(
|
|
`User lacks the "${requiredPerm}" permission required to mark an opportunity as ${payload.outcome}.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const targetStatus =
|
|
payload.outcome === "won"
|
|
? OpportunityStatus.PendingWon
|
|
: OpportunityStatus.PendingLost;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] ${payload.outcome === "won" ? "Pending Won" : "Pending Lost"} — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.Finalized,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* PendingWon / PendingLost → Active (resurrection / revision)
|
|
*
|
|
* Allows an admin (with finalize permission) to send a pending
|
|
* opportunity back to Active for revision.
|
|
*/
|
|
export async function resurrectOpportunity(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: ResurrectPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
// Only finalize-permissioned users can resurrect from PendingWon
|
|
if (
|
|
currentStatus === OpportunityStatus.PendingWon &&
|
|
!hasPermission(user, WorkflowPermissions.FINALIZE)
|
|
) {
|
|
return fail(
|
|
"You do not have permission to send a Pending Won opportunity back for revision.",
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const noteErr = assertNotePresent(payload.note);
|
|
if (noteErr) return fail(noteErr, currentStatus);
|
|
|
|
const targetStatus = OpportunityStatus.Active;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
const fromLabel =
|
|
currentStatus === OpportunityStatus.PendingWon
|
|
? "Pending Won"
|
|
: "Pending Lost";
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Resurrected from ${fromLabel} — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.Revision,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* PendingRevision → Active
|
|
*
|
|
* Rep begins revising the opportunity after review rejection.
|
|
*/
|
|
export async function beginRevision(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: BeginRevisionPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const targetStatus = OpportunityStatus.Active;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Revision started — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note ?? "Revision started after review rejection.",
|
|
optimaType: OptimaType.Revision,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note ?? "Revision started.",
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* ConfirmedQuote → PendingRevision
|
|
*
|
|
* Sends the opportunity back for revision from ConfirmedQuote.
|
|
* Requires a mandatory note explaining why.
|
|
*/
|
|
export async function sendBackForRevision(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: SendBackForRevisionPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const noteErr = assertNotePresent(payload.note);
|
|
if (noteErr) return fail(noteErr, currentStatus);
|
|
|
|
const targetStatus = OpportunityStatus.PendingRevision;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Sent back for revision — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.Revision,
|
|
});
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activity.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
|
|
return ok(currentStatus, targetStatus, [activity]);
|
|
}
|
|
|
|
/**
|
|
* Create a Schedule Entry activity without changing the opportunity status.
|
|
*
|
|
* Schedule Entry activities stay open until time is logged against them.
|
|
*/
|
|
export async function createScheduleEntry(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: CreateScheduleEntryPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
// CW activities require ISO-8601 without milliseconds, e.g. "2026-04-19T20:15:00Z"
|
|
const toCwDateTime = (iso: string): string => iso.replace(/\.\d+Z$/, "Z");
|
|
|
|
const dateStart = payload.startTime
|
|
? toCwDateTime(payload.startTime)
|
|
: payload.dueDate
|
|
? toCwDateTime(payload.dueDate)
|
|
: undefined;
|
|
const dateEnd = payload.endTime ? toCwDateTime(payload.endTime) : undefined;
|
|
|
|
// Find the currently open workflow activity (OpportunitySetup, OpportunityReview,
|
|
// or Revision) to use as the parent for this schedule entry.
|
|
let parentActivityCwId: number | null = null;
|
|
try {
|
|
const existingActivities = await activityCw.fetchByOpportunityDirect(
|
|
opportunity.cwOpportunityId,
|
|
);
|
|
for (const raw of existingActivities) {
|
|
if (raw.status?.id === 2) continue; // already closed
|
|
const optimaField = raw.customFields?.find(
|
|
(f: any) => f.id === OptimaType.FIELD_ID,
|
|
);
|
|
if (!optimaField?.value) continue;
|
|
if (optimaField.value === OptimaType.ScheduleEntry) continue; // skip other schedule entries
|
|
if (STAYS_OPEN_TYPES.has(optimaField.value as OptimaTypeValue)) {
|
|
parentActivityCwId = raw.id;
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Non-fatal — schedule entry will be created without a parent
|
|
console.warn(
|
|
`[Workflow:ScheduleEntry] Could not resolve parent activity: ${err}`,
|
|
);
|
|
}
|
|
|
|
const activity = await ActivityController.create({
|
|
name: `[Schedule Entry] ${payload.activityTypeValue} — ${opportunity.name}`,
|
|
type: { id: 3 }, // HistoricEntry
|
|
opportunity: { id: opportunity.cwOpportunityId },
|
|
...(opportunity.companyCwId ? { company: { id: opportunity.companyCwId } } : {}),
|
|
assignTo: { id: user.cwMemberId },
|
|
notes: payload.note ?? "",
|
|
...(dateStart ? { dateStart } : {}),
|
|
...(dateEnd ? { dateEnd } : {}),
|
|
});
|
|
|
|
// Build custom fields: always Optima_Type, plus Parent_Activity when resolved
|
|
const customFields: any[] = [
|
|
{
|
|
id: OptimaType.FIELD_ID,
|
|
caption: "Optima_Type",
|
|
type: "Text",
|
|
entryMethod: "List",
|
|
numberOfDecimals: 0,
|
|
value: OptimaType.ScheduleEntry,
|
|
},
|
|
];
|
|
|
|
if (parentActivityCwId != null) {
|
|
customFields.push({
|
|
id: PARENT_ACTIVITY_FIELD_ID,
|
|
caption: "Parent_Activity",
|
|
type: "Text",
|
|
entryMethod: "EntryField",
|
|
numberOfDecimals: 0,
|
|
value: String(parentActivityCwId),
|
|
});
|
|
}
|
|
|
|
// Set Optima_Type (+ Parent_Activity) on the new schedule entry
|
|
await activity.update([
|
|
{
|
|
op: "replace",
|
|
path: "customFields",
|
|
value: customFields,
|
|
},
|
|
]);
|
|
|
|
// Return a no-transition result (status unchanged)
|
|
return {
|
|
success: true,
|
|
previousStatusId: currentStatus,
|
|
newStatusId: currentStatus,
|
|
previousStatus: StatusIdToKey[currentStatus] ?? null,
|
|
newStatus: StatusIdToKey[currentStatus] ?? null,
|
|
activitiesCreated: [activity],
|
|
coldCheck: null,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Any cancelable status → Canceled
|
|
*
|
|
* Requires `sales.opportunity.cancel` permission.
|
|
*/
|
|
export async function cancelOpportunity(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: CancelPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const noteErr = assertNotePresent(payload.note);
|
|
if (noteErr) return fail(noteErr, currentStatus);
|
|
|
|
if (!hasPermission(user, WorkflowPermissions.CANCEL)) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.CANCEL}" permission required to cancel an opportunity.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const targetStatus = OpportunityStatus.Canceled;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Canceled — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.Finalized,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* Canceled → Active (re-open)
|
|
*
|
|
* Dedicated re-open action. Creates an audit trail activity with a
|
|
* required note explaining why the opportunity is being re-opened.
|
|
*/
|
|
export async function reopenCancelledOpportunity(
|
|
opportunity: OpportunityController,
|
|
user: WorkflowUser,
|
|
payload: ReopenPayload,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const noteErr = assertNotePresent(payload.note);
|
|
if (noteErr) return fail(noteErr, currentStatus);
|
|
|
|
if (!hasPermission(user, WorkflowPermissions.REOPEN)) {
|
|
return fail(
|
|
`User lacks the "${WorkflowPermissions.REOPEN}" permission required to re-open a cancelled opportunity.`,
|
|
currentStatus,
|
|
);
|
|
}
|
|
|
|
const targetStatus = OpportunityStatus.Active;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) return fail(transErr, currentStatus);
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow] Re-opened from Canceled — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: user.cwMemberId,
|
|
notes: payload.note,
|
|
optimaType: OptimaType.Revision,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
await handleTimeEntry(
|
|
activities[0]?.cwActivityId,
|
|
user.cwMemberId,
|
|
payload,
|
|
payload.note,
|
|
);
|
|
return ok(currentStatus, targetStatus, activities);
|
|
}
|
|
|
|
/**
|
|
* Cold detection automation trigger.
|
|
*
|
|
* → InternalReview (system-generated)
|
|
*
|
|
* Called by automation/scheduler, not by a user action.
|
|
* Evaluates the cold threshold and, if cold, transitions the
|
|
* opportunity to InternalReview with a system-generated activity.
|
|
*/
|
|
export async function triggerColdDetection(
|
|
opportunity: OpportunityController,
|
|
lastActivityDate: Date | null,
|
|
): Promise<WorkflowResult> {
|
|
const currentStatus = opportunity.statusCwId;
|
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
|
|
|
const coldResult = checkColdStatus({
|
|
statusCwId: currentStatus,
|
|
lastActivityDate,
|
|
});
|
|
|
|
if (!coldResult.cold) {
|
|
return {
|
|
success: true,
|
|
previousStatusId: currentStatus,
|
|
newStatusId: currentStatus,
|
|
previousStatus: StatusIdToKey[currentStatus] ?? null,
|
|
newStatus: StatusIdToKey[currentStatus] ?? null,
|
|
activitiesCreated: [],
|
|
coldCheck: coldResult,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
const targetStatus = OpportunityStatus.InternalReview;
|
|
|
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
|
if (transErr) {
|
|
return {
|
|
...fail(transErr, currentStatus),
|
|
coldCheck: coldResult,
|
|
};
|
|
}
|
|
|
|
const activities: ActivityController[] = [];
|
|
|
|
// System-generated activity — no user assignment, use a system marker.
|
|
// We use assignTo with a placeholder; the caller should provide a
|
|
// system member ID when integrating.
|
|
const trigger = coldResult.triggeredBy!;
|
|
const activity = await createWorkflowActivity({
|
|
name: `[Workflow][Auto] Cold detected — ${opportunity.name}`,
|
|
opportunityCwId: opportunity.cwOpportunityId,
|
|
companyCwId: opportunity.companyCwId,
|
|
assignToCwMemberId: 0, // system — will be resolved by caller
|
|
notes:
|
|
`Opportunity went cold. Status "${trigger.statusName}" exceeded ` +
|
|
`${trigger.thresholdDays}-day threshold (stale for ${trigger.staleDays} days). ` +
|
|
`Automatically moved to Internal Review.`,
|
|
optimaType: OptimaType.OpportunityReview,
|
|
});
|
|
activities.push(activity);
|
|
|
|
await syncOpportunityStatus({
|
|
opportunityId: opportunity.cwOpportunityId,
|
|
statusCwId: targetStatus,
|
|
});
|
|
|
|
return {
|
|
...ok(currentStatus, targetStatus, activities),
|
|
coldCheck: coldResult,
|
|
};
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CLOSE OPEN WORKFLOW ACTIVITIES
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Close any open workflow activities for this opportunity.
|
|
*
|
|
* Called at the start of every workflow transition so that activities
|
|
* marked as "stays open" (Opportunity Setup, Opportunity Review, Revision)
|
|
* are closed when the opportunity moves to the next stage.
|
|
*/
|
|
async function closeOpenWorkflowActivities(
|
|
opportunityCwId: number,
|
|
): Promise<void> {
|
|
try {
|
|
const activities =
|
|
await activityCw.fetchByOpportunityDirect(opportunityCwId);
|
|
|
|
for (const raw of activities) {
|
|
// Only close Open activities (status id !== 2)
|
|
if (raw.status?.id === 2) continue;
|
|
|
|
// Only close activities that have a workflow Optima_Type custom field
|
|
const optimaField = raw.customFields?.find(
|
|
(f: any) => f.id === OptimaType.FIELD_ID,
|
|
);
|
|
if (!optimaField?.value) continue;
|
|
|
|
// Only close activities whose type is in the stays-open set
|
|
if (!STAYS_OPEN_TYPES.has(optimaField.value as OptimaTypeValue)) continue;
|
|
|
|
// Never auto-close Schedule Entry activities — they close only when time is logged
|
|
if (optimaField.value === OptimaType.ScheduleEntry) continue;
|
|
|
|
const closeDate = new Date().toISOString();
|
|
const existingFields = (raw.customFields ?? []).map((f: any) =>
|
|
f.id === CLOSE_DATE_FIELD_ID ? { ...f, value: closeDate } : f,
|
|
);
|
|
// Add Close Date if it wasn't already present
|
|
if (!existingFields.some((f: any) => f.id === CLOSE_DATE_FIELD_ID)) {
|
|
existingFields.push({
|
|
id: CLOSE_DATE_FIELD_ID,
|
|
caption: "Close Date",
|
|
type: "Text",
|
|
entryMethod: "Date",
|
|
numberOfDecimals: 0,
|
|
value: closeDate,
|
|
});
|
|
}
|
|
|
|
const controller = new ActivityController(raw);
|
|
await controller.update([
|
|
{ op: "replace", path: "status", value: { id: 2 } },
|
|
{ op: "replace", path: "customFields", value: existingFields },
|
|
]);
|
|
}
|
|
} catch (err) {
|
|
console.error(
|
|
`[Workflow] Failed to close open activities for opportunity ${opportunityCwId}:`,
|
|
err,
|
|
);
|
|
// Non-fatal — don't block the transition
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// MASTER DISPATCHER
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Process an opportunity workflow action.
|
|
*
|
|
* This is the single entry point for all workflow transitions. It:
|
|
* 1. Validates the opportunity stage is "Optima".
|
|
* 2. Validates the opportunity is not in a terminal status.
|
|
* 3. Routes the action to the correct transition function.
|
|
* 4. On success, refreshes the opportunity from CW to keep
|
|
* the local database in sync.
|
|
*
|
|
* @param opportunity - The OpportunityController instance to act on.
|
|
* @param action - The discriminated action union.
|
|
* @param user - The authenticated user performing the action.
|
|
*/
|
|
export async function processOpportunityAction(
|
|
opportunity: OpportunityController,
|
|
{ action, payload }: WorkflowAction,
|
|
user: WorkflowUser,
|
|
): Promise<WorkflowResult> {
|
|
// ── Stage gate ──────────────────────────────────────────────────────
|
|
const stageErr = assertOptimaStage(opportunity);
|
|
if (stageErr) return fail(stageErr, opportunity.statusCwId);
|
|
|
|
// ── Terminal gate (except reopen which starts from Canceled) ────────
|
|
if (action !== "reopen") {
|
|
const termErr = assertNotTerminal(opportunity.statusCwId);
|
|
if (termErr) return fail(termErr, opportunity.statusCwId);
|
|
}
|
|
|
|
// ── Close any open workflow activities from previous stage ──────────
|
|
// Skip for createScheduleEntry — we intentionally preserve open activities.
|
|
if (action !== "createScheduleEntry") {
|
|
await closeOpenWorkflowActivities(opportunity.cwOpportunityId);
|
|
}
|
|
|
|
// ── Route to transition function ────────────────────────────────────
|
|
let result: WorkflowResult;
|
|
|
|
switch (action) {
|
|
case "acceptNew":
|
|
result = await transitionToNew(opportunity, user, payload);
|
|
break;
|
|
|
|
case "requestReview":
|
|
result = await transitionToInternalReview(opportunity, user, payload);
|
|
break;
|
|
|
|
case "reviewDecision":
|
|
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;
|
|
|
|
case "confirmQuote":
|
|
result = await transitionToConfirmedQuote(opportunity, user, payload);
|
|
break;
|
|
|
|
case "finalize": {
|
|
// If user has finalize permission, go directly to Won/Lost.
|
|
// Otherwise, go to PendingWon/PendingLost.
|
|
result = hasPermission(user, WorkflowPermissions.FINALIZE)
|
|
? await finalizeOpportunity(opportunity, user, payload)
|
|
: await transitionToPending(opportunity, user, payload);
|
|
break;
|
|
}
|
|
|
|
case "resurrect":
|
|
result = await resurrectOpportunity(opportunity, user, payload);
|
|
break;
|
|
|
|
case "beginRevision":
|
|
result = await beginRevision(opportunity, user, payload);
|
|
break;
|
|
|
|
case "resendQuote":
|
|
result = await transitionToQuoteSent(opportunity, user, payload);
|
|
break;
|
|
|
|
case "cancel":
|
|
result = await cancelOpportunity(opportunity, user, payload);
|
|
break;
|
|
|
|
case "reopen":
|
|
result = await reopenCancelledOpportunity(opportunity, user, payload);
|
|
break;
|
|
|
|
case "sendBackForRevision":
|
|
result = await sendBackForRevision(opportunity, user, payload);
|
|
break;
|
|
|
|
case "createScheduleEntry":
|
|
// Schedule Entry does not close open activities — skip that step.
|
|
// We call it directly rather than falling through closeOpenWorkflowActivities.
|
|
result = await createScheduleEntry(opportunity, user, payload);
|
|
break;
|
|
|
|
default: {
|
|
const _exhaustive: never = action;
|
|
return fail(`Unknown workflow action: "${_exhaustive}"`);
|
|
}
|
|
}
|
|
|
|
// ── Post-transition: refresh opportunity from CW → local DB ────────
|
|
if (result.success) {
|
|
try {
|
|
await opportunity.refreshFromCW();
|
|
} catch (refreshErr) {
|
|
console.error(
|
|
"[Workflow] Failed to refresh opportunity from CW after transition:",
|
|
refreshErr,
|
|
);
|
|
// Don't fail the workflow — the transition itself succeeded.
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|