task f.3
This commit is contained in:
85
src/features/crm/approval/schemas/approval-matrix.schema.ts
Normal file
85
src/features/crm/approval/schemas/approval-matrix.schema.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const nullableTrimmedString = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => value ?? null);
|
||||
|
||||
const nullableNumber = z
|
||||
.coerce.number()
|
||||
.finite()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => value ?? null);
|
||||
|
||||
const nullableBoolean = z
|
||||
.boolean()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => value ?? null);
|
||||
|
||||
export const approvalMatrixBaseSchema = z
|
||||
.object({
|
||||
entityType: z.string().trim().min(1),
|
||||
workflowId: z.string().trim().min(1),
|
||||
branchId: nullableTrimmedString,
|
||||
productType: nullableTrimmedString,
|
||||
minAmount: nullableNumber,
|
||||
maxAmount: nullableNumber,
|
||||
currency: nullableTrimmedString,
|
||||
priority: z.number().int(),
|
||||
isDefault: z.boolean(),
|
||||
isActive: z.boolean(),
|
||||
description: nullableTrimmedString
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
value.minAmount !== null &&
|
||||
value.maxAmount !== null &&
|
||||
value.minAmount > value.maxAmount
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['minAmount'],
|
||||
message: 'minAmount must be less than or equal to maxAmount'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const createApprovalMatrixSchema = approvalMatrixBaseSchema;
|
||||
|
||||
export const updateApprovalMatrixSchema = z
|
||||
.object({
|
||||
entityType: z.string().trim().min(1).optional(),
|
||||
workflowId: z.string().trim().min(1).optional(),
|
||||
branchId: nullableTrimmedString.optional(),
|
||||
productType: nullableTrimmedString.optional(),
|
||||
minAmount: nullableNumber.optional(),
|
||||
maxAmount: nullableNumber.optional(),
|
||||
currency: nullableTrimmedString.optional(),
|
||||
priority: z.number().int().optional(),
|
||||
isDefault: nullableBoolean.optional(),
|
||||
isActive: nullableBoolean.optional(),
|
||||
description: nullableTrimmedString.optional()
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
value.minAmount !== undefined &&
|
||||
value.maxAmount !== undefined &&
|
||||
value.minAmount !== null &&
|
||||
value.maxAmount !== null &&
|
||||
value.minAmount > value.maxAmount
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['minAmount'],
|
||||
message: 'minAmount must be less than or equal to maxAmount'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateApprovalMatrixInput = z.infer<typeof createApprovalMatrixSchema>;
|
||||
export type UpdateApprovalMatrixInput = z.infer<typeof updateApprovalMatrixSchema>;
|
||||
497
src/features/crm/approval/server/service.ts
Normal file
497
src/features/crm/approval/server/service.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
import { and, asc, count, desc, eq, isNull, ne, or } from 'drizzle-orm';
|
||||
import { crmApprovalMatrices, crmApprovalWorkflows, crmQuotations } from '@/db/schema';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { submitForApproval } from '@/features/foundation/approval/server/service';
|
||||
import {
|
||||
type CrmSecurityContext,
|
||||
auditCrmSecurityEvent,
|
||||
canAccessScopedCrmRecord
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import type {
|
||||
CreateApprovalMatrixInput,
|
||||
UpdateApprovalMatrixInput
|
||||
} from '../schemas/approval-matrix.schema';
|
||||
|
||||
export interface ApprovalMatrixRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
entityType: string;
|
||||
workflowId: string;
|
||||
branchId: string | null;
|
||||
productType: string | null;
|
||||
minAmount: number | null;
|
||||
maxAmount: number | null;
|
||||
currency: string | null;
|
||||
priority: number;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface ResolveApprovalWorkflowInput {
|
||||
organizationId: string;
|
||||
entityType: 'quotation';
|
||||
branchId?: string | null;
|
||||
productType?: string | null;
|
||||
amount?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowResolution {
|
||||
matrixId: string;
|
||||
workflowId: string;
|
||||
matchReason: string;
|
||||
}
|
||||
|
||||
interface MatrixCandidate {
|
||||
row: typeof crmApprovalMatrices.$inferSelect;
|
||||
specificity: number;
|
||||
matchReason: string;
|
||||
}
|
||||
|
||||
function mapApprovalMatrixRecord(row: typeof crmApprovalMatrices.$inferSelect): ApprovalMatrixRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
entityType: row.entityType,
|
||||
workflowId: row.workflowId,
|
||||
branchId: row.branchId ?? null,
|
||||
productType: row.productType ?? null,
|
||||
minAmount: row.minAmount ?? null,
|
||||
maxAmount: row.maxAmount ?? null,
|
||||
currency: row.currency ?? null,
|
||||
priority: row.priority,
|
||||
isDefault: row.isDefault,
|
||||
isActive: row.isActive,
|
||||
description: row.description ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy
|
||||
};
|
||||
}
|
||||
|
||||
async function assertWorkflowExists(workflowId: string, organizationId: string, entityType?: string) {
|
||||
const [workflow] = await db
|
||||
.select()
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalWorkflows.id, workflowId),
|
||||
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||
eq(crmApprovalWorkflows.isActive, true),
|
||||
isNull(crmApprovalWorkflows.deletedAt),
|
||||
...(entityType ? [eq(crmApprovalWorkflows.entityType, entityType)] : [])
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!workflow) {
|
||||
throw new AuthError('Approval workflow not found', 404);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async function assertApprovalMatrix(id: string, organizationId: string) {
|
||||
const [matrix] = await db
|
||||
.select()
|
||||
.from(crmApprovalMatrices)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalMatrices.id, id),
|
||||
eq(crmApprovalMatrices.organizationId, organizationId),
|
||||
isNull(crmApprovalMatrices.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!matrix) {
|
||||
throw new AuthError('Approval matrix not found', 404);
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
function buildMatchReason(parts: string[], row: typeof crmApprovalMatrices.$inferSelect) {
|
||||
const reasons = [...parts];
|
||||
if (row.isDefault) {
|
||||
reasons.push('default fallback');
|
||||
}
|
||||
reasons.push(`priority ${row.priority}`);
|
||||
return reasons.join(', ');
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
row: typeof crmApprovalMatrices.$inferSelect,
|
||||
input: ResolveApprovalWorkflowInput
|
||||
): MatrixCandidate | null {
|
||||
if (!row.isActive || row.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.productType && row.productType !== input.productType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.branchId && row.branchId !== input.branchId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.currency && row.currency !== input.currency) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.minAmount !== null && row.minAmount !== undefined) {
|
||||
if (input.amount === null || input.amount === undefined || input.amount < row.minAmount) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (row.maxAmount !== null && row.maxAmount !== undefined) {
|
||||
if (input.amount === null || input.amount === undefined || input.amount > row.maxAmount) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let specificity = row.isDefault ? -100 : 0;
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (row.productType) {
|
||||
specificity += 10;
|
||||
reasons.push('product type');
|
||||
}
|
||||
|
||||
if (row.branchId) {
|
||||
specificity += 10;
|
||||
reasons.push('branch');
|
||||
}
|
||||
|
||||
if (row.currency) {
|
||||
specificity += 5;
|
||||
reasons.push('currency');
|
||||
}
|
||||
|
||||
if (row.minAmount !== null || row.maxAmount !== null) {
|
||||
specificity += 5;
|
||||
reasons.push('amount range');
|
||||
}
|
||||
|
||||
return {
|
||||
row,
|
||||
specificity,
|
||||
matchReason: buildMatchReason(reasons, row)
|
||||
};
|
||||
}
|
||||
|
||||
export async function listApprovalMatrices(organizationId: string): Promise<ApprovalMatrixRecord[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmApprovalMatrices)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalMatrices.organizationId, organizationId),
|
||||
isNull(crmApprovalMatrices.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(crmApprovalMatrices.entityType),
|
||||
asc(crmApprovalMatrices.priority),
|
||||
desc(crmApprovalMatrices.updatedAt)
|
||||
);
|
||||
|
||||
return rows.map(mapApprovalMatrixRecord);
|
||||
}
|
||||
|
||||
export async function getApprovalMatrix(id: string, organizationId: string): Promise<ApprovalMatrixRecord> {
|
||||
const matrix = await assertApprovalMatrix(id, organizationId);
|
||||
return mapApprovalMatrixRecord(matrix);
|
||||
}
|
||||
|
||||
export async function createApprovalMatrix(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CreateApprovalMatrixInput
|
||||
): Promise<ApprovalMatrixRecord> {
|
||||
await assertWorkflowExists(payload.workflowId, organizationId, payload.entityType);
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmApprovalMatrices)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
entityType: payload.entityType.trim(),
|
||||
workflowId: payload.workflowId.trim(),
|
||||
branchId: payload.branchId,
|
||||
productType: payload.productType,
|
||||
minAmount: payload.minAmount,
|
||||
maxAmount: payload.maxAmount,
|
||||
currency: payload.currency,
|
||||
priority: payload.priority,
|
||||
isDefault: payload.isDefault,
|
||||
isActive: payload.isActive,
|
||||
description: payload.description,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapApprovalMatrixRecord(created);
|
||||
}
|
||||
|
||||
export async function updateApprovalMatrix(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: UpdateApprovalMatrixInput
|
||||
): Promise<ApprovalMatrixRecord> {
|
||||
const current = await assertApprovalMatrix(id, organizationId);
|
||||
|
||||
const nextEntityType = payload.entityType?.trim() ?? current.entityType;
|
||||
const nextWorkflowId = payload.workflowId?.trim() ?? current.workflowId;
|
||||
const nextMinAmount = payload.minAmount !== undefined ? payload.minAmount : current.minAmount;
|
||||
const nextMaxAmount = payload.maxAmount !== undefined ? payload.maxAmount : current.maxAmount;
|
||||
|
||||
if (nextMinAmount !== null && nextMaxAmount !== null && nextMinAmount > nextMaxAmount) {
|
||||
throw new AuthError('minAmount must be less than or equal to maxAmount', 400);
|
||||
}
|
||||
|
||||
await assertWorkflowExists(nextWorkflowId, organizationId, nextEntityType);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmApprovalMatrices)
|
||||
.set({
|
||||
entityType: nextEntityType,
|
||||
workflowId: nextWorkflowId,
|
||||
branchId: payload.branchId !== undefined ? payload.branchId : current.branchId,
|
||||
productType: payload.productType !== undefined ? payload.productType : current.productType,
|
||||
minAmount: nextMinAmount,
|
||||
maxAmount: nextMaxAmount,
|
||||
currency: payload.currency !== undefined ? payload.currency : current.currency,
|
||||
priority: payload.priority ?? current.priority,
|
||||
isDefault: payload.isDefault ?? current.isDefault,
|
||||
isActive: payload.isActive ?? current.isActive,
|
||||
description: payload.description !== undefined ? payload.description : current.description,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmApprovalMatrices.id, id))
|
||||
.returning();
|
||||
|
||||
return mapApprovalMatrixRecord(updated);
|
||||
}
|
||||
|
||||
export async function softDeleteApprovalMatrix(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
): Promise<{ before: ApprovalMatrixRecord; after: ApprovalMatrixRecord }> {
|
||||
const current = await assertApprovalMatrix(id, organizationId);
|
||||
|
||||
if (current.isDefault && current.isActive) {
|
||||
const [defaultCount] = await db
|
||||
.select({ value: count() })
|
||||
.from(crmApprovalMatrices)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalMatrices.organizationId, organizationId),
|
||||
eq(crmApprovalMatrices.entityType, current.entityType),
|
||||
eq(crmApprovalMatrices.isDefault, true),
|
||||
eq(crmApprovalMatrices.isActive, true),
|
||||
isNull(crmApprovalMatrices.deletedAt),
|
||||
ne(crmApprovalMatrices.id, id)
|
||||
)
|
||||
);
|
||||
|
||||
if (!defaultCount || defaultCount.value === 0) {
|
||||
throw new AuthError('At least one active default matrix is required for this entity type', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmApprovalMatrices)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmApprovalMatrices.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
before: mapApprovalMatrixRecord(current),
|
||||
after: mapApprovalMatrixRecord(updated)
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveApprovalWorkflowForEntity(
|
||||
input: ResolveApprovalWorkflowInput
|
||||
): Promise<ApprovalWorkflowResolution> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmApprovalMatrices)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalMatrices.organizationId, input.organizationId),
|
||||
eq(crmApprovalMatrices.entityType, input.entityType),
|
||||
eq(crmApprovalMatrices.isActive, true),
|
||||
isNull(crmApprovalMatrices.deletedAt),
|
||||
or(eq(crmApprovalMatrices.branchId, input.branchId ?? ''), isNull(crmApprovalMatrices.branchId)),
|
||||
or(
|
||||
eq(crmApprovalMatrices.productType, input.productType ?? ''),
|
||||
isNull(crmApprovalMatrices.productType)
|
||||
),
|
||||
or(eq(crmApprovalMatrices.currency, input.currency ?? ''), isNull(crmApprovalMatrices.currency))
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmApprovalMatrices.createdAt), desc(crmApprovalMatrices.id));
|
||||
|
||||
const candidates = rows
|
||||
.map((row) => buildCandidate(row, input))
|
||||
.filter((candidate): candidate is MatrixCandidate => candidate !== null);
|
||||
|
||||
if (!candidates.length) {
|
||||
throw new AuthError('No approval matrix matched and no default matrix is configured', 400);
|
||||
}
|
||||
|
||||
const specificCandidates = candidates.filter((candidate) => !candidate.row.isDefault);
|
||||
const defaultCandidates = candidates.filter((candidate) => candidate.row.isDefault);
|
||||
const workingSet = specificCandidates.length > 0 ? specificCandidates : defaultCandidates;
|
||||
|
||||
if (!workingSet.length) {
|
||||
throw new AuthError('No default approval matrix is configured for this entity type', 400);
|
||||
}
|
||||
|
||||
const ranked = [...workingSet].sort((left, right) => {
|
||||
if (left.specificity !== right.specificity) {
|
||||
return right.specificity - left.specificity;
|
||||
}
|
||||
|
||||
if (left.row.priority !== right.row.priority) {
|
||||
return left.row.priority - right.row.priority;
|
||||
}
|
||||
|
||||
const createdDiff = right.row.createdAt.getTime() - left.row.createdAt.getTime();
|
||||
if (createdDiff !== 0) {
|
||||
return createdDiff;
|
||||
}
|
||||
|
||||
return right.row.id.localeCompare(left.row.id);
|
||||
});
|
||||
|
||||
const [winner, runnerUp] = ranked;
|
||||
|
||||
if (
|
||||
runnerUp &&
|
||||
winner.specificity === runnerUp.specificity &&
|
||||
winner.row.priority === runnerUp.row.priority &&
|
||||
winner.row.createdAt.getTime() === runnerUp.row.createdAt.getTime()
|
||||
) {
|
||||
throw new AuthError(
|
||||
`Approval matrix conflict between ${winner.row.id} and ${runnerUp.row.id}`,
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
await assertWorkflowExists(winner.row.workflowId, input.organizationId, input.entityType);
|
||||
|
||||
return {
|
||||
matrixId: winner.row.id,
|
||||
workflowId: winner.row.workflowId,
|
||||
matchReason: winner.matchReason
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitQuotationForApproval(
|
||||
input: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
quotationId: string;
|
||||
remark?: string;
|
||||
accessContext: CrmSecurityContext;
|
||||
}
|
||||
) {
|
||||
const [quotation] = await db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.id, input.quotationId),
|
||||
eq(crmQuotations.organizationId, input.organizationId),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!quotation) {
|
||||
throw new AuthError('Quotation not found', 404);
|
||||
}
|
||||
|
||||
const canAccess = canAccessScopedCrmRecord(input.accessContext, {
|
||||
branchId: quotation.branchId,
|
||||
productType: quotation.quotationType,
|
||||
createdBy: quotation.createdBy,
|
||||
ownerUserId: quotation.salesmanId
|
||||
});
|
||||
|
||||
if (!canAccess) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
action: 'approval_scope_violation',
|
||||
entityType: 'crm_quotation',
|
||||
entityId: input.quotationId,
|
||||
reason: 'User attempted to submit approval outside resolved CRM scope',
|
||||
metadata: {
|
||||
branchId: quotation.branchId,
|
||||
productType: quotation.quotationType
|
||||
}
|
||||
});
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
const resolution = await resolveApprovalWorkflowForEntity({
|
||||
organizationId: input.organizationId,
|
||||
entityType: 'quotation',
|
||||
branchId: quotation.branchId,
|
||||
productType: quotation.quotationType,
|
||||
amount: quotation.totalAmount,
|
||||
currency: quotation.currency
|
||||
});
|
||||
|
||||
const request = await submitForApproval(input.organizationId, input.userId, {
|
||||
workflowId: resolution.workflowId,
|
||||
entityType: 'quotation',
|
||||
entityId: input.quotationId,
|
||||
remark: input.remark
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
entityType: 'crm_approval_matrix',
|
||||
entityId: resolution.matrixId,
|
||||
action: 'resolve_approval_matrix',
|
||||
afterData: {
|
||||
quotationId: input.quotationId,
|
||||
approvalRequestId: request.id,
|
||||
matrixId: resolution.matrixId,
|
||||
workflowId: resolution.workflowId,
|
||||
matchReason: resolution.matchReason,
|
||||
productType: quotation.quotationType,
|
||||
branchId: quotation.branchId,
|
||||
amount: quotation.totalAmount,
|
||||
currency: quotation.currency
|
||||
}
|
||||
});
|
||||
|
||||
return { request, resolution };
|
||||
}
|
||||
Reference in New Issue
Block a user