This commit is contained in:
phaichayon
2026-06-26 11:03:25 +07:00
parent 42c8c59947
commit 052677fa2c
16 changed files with 1604 additions and 68 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { submitForApproval } from '@/features/foundation/approval/server/service';
import { submitQuotationForApproval } from '@/features/crm/approval/server/service';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -15,20 +16,26 @@ const submitSchema = z.object({
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalSubmit
});
const payload = submitSchema.parse(await request.json());
await submitForApproval(organization.id, session.user.id, {
workflowCode: 'quotation_standard_approval',
entityType: 'quotation',
entityId: id,
remark: payload.remark
await submitQuotationForApproval({
organizationId: organization.id,
userId: session.user.id,
quotationId: id,
remark: payload.remark,
accessContext: buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
});
return NextResponse.json({
success: true,
message: 'Quotation submitted for approval successfully'
message: 'Quotation submitted approval successfully'
});
} catch (error) {
if (error instanceof AuthError) {
@@ -43,6 +50,6 @@ export async function POST(request: NextRequest, { params }: Params) {
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to submit quotation for approval' }, { status: 500 });
return NextResponse.json({ message: 'Unable submit quotation for approval' }, { status: 500 });
}
}

View File

@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
updateApprovalMatrixSchema,
type UpdateApprovalMatrixInput
} from '@/features/crm/approval/schemas/approval-matrix.schema';
import {
getApprovalMatrix,
softDeleteApprovalMatrix,
updateApprovalMatrix
} from '@/features/crm/approval/server/service';
import { auditAction } from '@/features/foundation/audit-log/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalMatrixRead
});
const matrix = await getApprovalMatrix(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Approval matrix loaded successfully',
matrix
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approval matrix' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalMatrixUpdate
});
const payload = updateApprovalMatrixSchema.parse(
await request.json()
) as UpdateApprovalMatrixInput;
const before = await getApprovalMatrix(id, organization.id);
const updated = await updateApprovalMatrix(id, organization.id, session.user.id, payload);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_matrix',
entityId: id,
action: 'update_approval_matrix',
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Approval matrix updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update approval matrix' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalMatrixDelete
});
const result = await softDeleteApprovalMatrix(id, organization.id, session.user.id);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_matrix',
entityId: id,
action: 'delete_approval_matrix',
beforeData: result.before,
afterData: result.after
});
return NextResponse.json({
success: true,
message: 'Approval matrix deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete approval matrix' }, { status: 500 });
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
createApprovalMatrixSchema,
type CreateApprovalMatrixInput
} from '@/features/crm/approval/schemas/approval-matrix.schema';
import {
createApprovalMatrix,
listApprovalMatrices
} from '@/features/crm/approval/server/service';
import { auditAction } from '@/features/foundation/audit-log/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(_request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalMatrixRead
});
const items = await listApprovalMatrices(organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Approval matrices loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approval matrices' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalMatrixCreate
});
const payload = createApprovalMatrixSchema.parse(
await request.json()
) as CreateApprovalMatrixInput;
const created = await createApprovalMatrix(organization.id, session.user.id, payload);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_matrix',
entityId: created.id,
action: 'create_approval_matrix',
afterData: created
});
return NextResponse.json({
success: true,
message: 'Approval matrix created successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create approval matrix' }, { status: 500 });
}
}

View File

@@ -1,52 +1,6 @@
import { NavGroup } from "@/types";
/** * Navigation configuration with RBAC support * * This configuration is used for both the sidebar navigation and Cmd+K bar. * Items are organized into groups, each rendered with a SidebarGroupLabel. * * RBAC Access Control: * Each navigation item can have an `access` property that controls visibility * based on permissions, plans, features, roles, and organization context. * * Examples: * * 1. Require organization: * access: { requireOrg: true } * * 2. Require specific permission: * access: { requireOrg: true, permission: 'org:teams:manage' } * * 3. Require specific plan: * access: { plan: 'pro' } * * 4. Require specific feature: * access: { feature: 'premium_access' } * * 5. Require specific role: * access: { role: 'admin' } * * 6. Multiple conditions (all must be true): * access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' } * * Note: The `visible` function is deprecated but still supported for backward compatibility. * Use the `access` property for new items. */ export const navGroups: NavGroup[] =
[
// {
// label: "Overview",
// items: [
// {
// title: "Dashboard Overview",
// url: "/dashboard",
// icon: "dashboard",
// isActive: false,
// shortcut: ["d", "d"],
// items: [],
// },
// {
// title: "Workspaces",
// url: "/dashboard/workspaces",
// icon: "workspace",
// isActive: false,
// items: [],
// access: { systemRole: "super_admin" },
// },
// {
// title: "Teams",
// url: "/dashboard/workspaces/team",
// icon: "teams",
// isActive: false,
// items: [],
// access: { requireOrg: true, permission: "crm.quotation.read" },
// },
// {
// title: "Users",
// url: "/dashboard/users",
// icon: "teams",
// shortcut: ["u", "u"],
// isActive: false,
// items: [],
// access: { requireOrg: true, permission: "users:manage" },
// },
// {
// title: "Kanban",
// url: "/dashboard/kanban",
// icon: "kanban",
// shortcut: ["k", "k"],
// isActive: false,
// items: [],
// },
// ],
// },
{
label: "CRM",
items: [
@@ -181,4 +135,50 @@ import { NavGroup } from "@/types";
},
],
},
{
label: "Admin",
items: [
// {
// title: "Dashboard Overview",
// url: "/dashboard",
// icon: "dashboard",
// isActive: false,
// shortcut: ["d", "d"],
// items: [],
// },
{
title: "Workspaces",
url: "/dashboard/workspaces",
icon: "workspace",
isActive: false,
items: [],
access: { systemRole: "super_admin" },
},
{
title: "Teams",
url: "/dashboard/workspaces/team",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.quotation.read" },
},
{
title: "Users",
url: "/dashboard/users",
icon: "teams",
shortcut: ["u", "u"],
isActive: false,
items: [],
access: { requireOrg: true, permission: "users:manage" },
},
// {
// title: "Kanban",
// url: "/dashboard/kanban",
// icon: "kanban",
// shortcut: ["k", "k"],
// isActive: false,
// items: [],
// },
],
},
];

View File

@@ -680,6 +680,27 @@ export const crmApprovalSteps = pgTable(
})
);
export const crmApprovalMatrices = pgTable('crm_approval_matrices', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
entityType: text('entity_type').notNull(),
workflowId: text('workflow_id').notNull(),
branchId: text('branch_id'),
productType: text('product_type'),
minAmount: doublePrecision('min_amount'),
maxAmount: doublePrecision('max_amount'),
currency: text('currency'),
priority: integer('priority').default(100).notNull(),
isDefault: boolean('is_default').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull()
});
export const crmApprovalRequests = pgTable('crm_approval_requests', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),

View File

@@ -666,6 +666,22 @@ const APPROVAL_WORKFLOWS = [
}
];
const APPROVAL_MATRIX_DEFAULTS = [
{
entityType: 'quotation',
workflowCode: 'quotation_standard_approval',
branchId: null,
productType: null,
minAmount: null,
maxAmount: null,
currency: null,
priority: 999,
isDefault: true,
isActive: true,
description: 'Default quotation approval matrix'
}
];
const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
{
placeholderKey: 'customer_name',
@@ -1031,6 +1047,95 @@ async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizati
}
}
async function upsertApprovalMatricesForOrganization(
sql: SqlClient,
organization: { id: string; createdBy: string }
) {
const workflows = await sql`
select id, code
from crm_approval_workflows
where organization_id = ${organization.id}
and deleted_at is null
`;
const workflowIdByCode = new Map(
workflows.map((workflow: { code: string; id: string }) => [workflow.code, workflow.id])
);
for (const matrix of APPROVAL_MATRIX_DEFAULTS) {
const workflowId = workflowIdByCode.get(matrix.workflowCode);
if (!workflowId) {
continue;
}
const [existing] = await sql`
select id
from crm_approval_matrices
where organization_id = ${organization.id}
and entity_type = ${matrix.entityType}
and is_default = ${matrix.isDefault}
and deleted_at is null
order by created_at asc
limit 1
`;
if (existing?.id) {
await sql`
update crm_approval_matrices
set workflow_id = ${workflowId},
branch_id = ${matrix.branchId},
product_type = ${matrix.productType},
min_amount = ${matrix.minAmount},
max_amount = ${matrix.maxAmount},
currency = ${matrix.currency},
priority = ${matrix.priority},
is_active = ${matrix.isActive},
description = ${matrix.description},
updated_at = now(),
updated_by = ${organization.createdBy}
where id = ${existing.id}
`;
continue;
}
await sql`
insert into crm_approval_matrices (
id,
organization_id,
entity_type,
workflow_id,
branch_id,
product_type,
min_amount,
max_amount,
currency,
priority,
is_default,
is_active,
description,
created_by,
updated_by
) values (
${crypto.randomUUID()},
${organization.id},
${matrix.entityType},
${workflowId},
${matrix.branchId},
${matrix.productType},
${matrix.minAmount},
${matrix.maxAmount},
${matrix.currency},
${matrix.priority},
${matrix.isDefault},
${matrix.isActive},
${matrix.description},
${organization.createdBy},
${organization.createdBy}
)
`;
}
}
async function upsertDocumentTemplatesForOrganization(
sql: SqlClient,
organization: { id: string; name: string; createdBy: string }
@@ -1336,12 +1441,13 @@ async function main() {
);
}
for (const organization of organizations) {
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
await upsertDocumentTemplatesForOrganization(sql, organization);
await upsertReportDefinitionsForOrganization(sql, organization);
for (const organization of organizations) {
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
await upsertApprovalMatricesForOrganization(sql, organization);
await upsertDocumentTemplatesForOrganization(sql, organization);
await upsertReportDefinitionsForOrganization(sql, organization);
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
}
} finally {

View 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>;

View 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 };
}

View File

@@ -987,11 +987,13 @@ export async function submitForApproval(
userId: string,
payload: SubmitApprovalPayload
) {
const workflow = await assertWorkflowByCode(
organizationId,
payload.workflowCode ?? `${payload.entityType}_standard_approval`,
payload.entityType
);
const workflow = payload.workflowId
? await assertWorkflowById(payload.workflowId, organizationId)
: await assertWorkflowByCode(
organizationId,
payload.workflowCode ?? `${payload.entityType}_standard_approval`,
payload.entityType
);
const steps = await listWorkflowSteps(workflow.id, organizationId);
if (!steps.length) {

View File

@@ -96,6 +96,7 @@ export interface ApprovalFilters {
}
export interface SubmitApprovalPayload {
workflowId?: string;
workflowCode?: string;
entityType: string;
entityId: string;

View File

@@ -81,6 +81,10 @@ export const PERMISSIONS = {
crmApprovalApprove: 'crm.approval.approve',
crmApprovalReject: 'crm.approval.reject',
crmApprovalReturn: 'crm.approval.return',
crmApprovalMatrixRead: 'crm.approval.matrix.read',
crmApprovalMatrixCreate: 'crm.approval.matrix.create',
crmApprovalMatrixUpdate: 'crm.approval.matrix.update',
crmApprovalMatrixDelete: 'crm.approval.matrix.delete',
crmApprovalWorkflowRead: 'crm.approval.workflow.read',
crmApprovalWorkflowCreate: 'crm.approval.workflow.create',
crmApprovalWorkflowUpdate: 'crm.approval.workflow.update',
@@ -441,6 +445,10 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmMasterOptionCreate,
PERMISSIONS.crmMasterOptionUpdate,
PERMISSIONS.crmMasterOptionDelete,
PERMISSIONS.crmApprovalMatrixRead,
PERMISSIONS.crmApprovalMatrixCreate,
PERMISSIONS.crmApprovalMatrixUpdate,
PERMISSIONS.crmApprovalMatrixDelete,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmApprovalWorkflowCreate,
PERMISSIONS.crmApprovalWorkflowUpdate,
@@ -597,6 +605,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
{ key: PERMISSIONS.crmMasterOptionDelete, label: 'Delete master options' },
{ key: PERMISSIONS.crmApprovalMatrixRead, label: 'Read approval matrices' },
{ key: PERMISSIONS.crmApprovalMatrixCreate, label: 'Create approval matrices' },
{ key: PERMISSIONS.crmApprovalMatrixUpdate, label: 'Update approval matrices' },
{ key: PERMISSIONS.crmApprovalMatrixDelete, label: 'Delete approval matrices' },
{ key: PERMISSIONS.crmApprovalWorkflowRead, label: 'Read approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },