feat(G2-1,G2-2,G2-3): 扩展预算编号与工作流跨域桥接
这个提交包含在:
@@ -0,0 +1,25 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "budget_ledger_entries" ADD COLUMN "entity_id" UUID,
|
||||||
|
ADD COLUMN "entity_type" VARCHAR(32);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "budget_pools" ADD COLUMN "cost_center_id" UUID,
|
||||||
|
ADD COLUMN "legal_entity_id" UUID;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "workflow_instances" ADD COLUMN "purchase_request_id" UUID;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "idx_budget_ledger_entity" ON "budget_ledger_entries"("entity_type", "entity_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "idx_budget_pools_scope_status" ON "budget_pools"("legal_entity_id", "cost_center_id", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "workflow_instances_purchase_request_id_idx" ON "workflow_instances"("purchase_request_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "budget_pools" ADD CONSTRAINT "budget_pools_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "budget_pools" ADD CONSTRAINT "budget_pools_cost_center_id_fkey" FOREIGN KEY ("cost_center_id") REFERENCES "cost_centers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -410,6 +410,7 @@ model LegalEntity {
|
|||||||
employees Employee[]
|
employees Employee[]
|
||||||
holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity")
|
holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity")
|
||||||
suppliers Supplier[]
|
suppliers Supplier[]
|
||||||
|
budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity")
|
||||||
|
|
||||||
@@index([enabled, archived_at], map: "idx_legal_entities_status")
|
@@index([enabled, archived_at], map: "idx_legal_entities_status")
|
||||||
@@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar")
|
@@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar")
|
||||||
@@ -435,6 +436,7 @@ model CostCenter {
|
|||||||
parent CostCenter? @relation("CostCenterToParent", fields: [parent_id], references: [id], onDelete: Restrict)
|
parent CostCenter? @relation("CostCenterToParent", fields: [parent_id], references: [id], onDelete: Restrict)
|
||||||
children CostCenter[] @relation("CostCenterToParent")
|
children CostCenter[] @relation("CostCenterToParent")
|
||||||
owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
|
owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
|
||||||
|
budget_pools BudgetPool[] @relation("BudgetPoolCostCenter")
|
||||||
|
|
||||||
@@unique([legal_entity_id, code], map: "legal_entity_cost_center_code_ux")
|
@@unique([legal_entity_id, code], map: "legal_entity_cost_center_code_ux")
|
||||||
@@index([legal_entity_id, enabled, archived_at], map: "idx_cost_centers_legal_entity_status")
|
@@index([legal_entity_id, enabled, archived_at], map: "idx_cost_centers_legal_entity_status")
|
||||||
@@ -824,6 +826,8 @@ model BudgetPool {
|
|||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
department_id String? @db.Uuid
|
department_id String? @db.Uuid
|
||||||
|
legal_entity_id String? @db.Uuid
|
||||||
|
cost_center_id String? @db.Uuid
|
||||||
status String @default("active") @db.VarChar(16)
|
status String @default("active") @db.VarChar(16)
|
||||||
period_start DateTime @db.Date
|
period_start DateTime @db.Date
|
||||||
period_end DateTime @db.Date
|
period_end DateTime @db.Date
|
||||||
@@ -838,9 +842,12 @@ model BudgetPool {
|
|||||||
updated_by String? @db.Uuid
|
updated_by String? @db.Uuid
|
||||||
|
|
||||||
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
|
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
|
||||||
|
legal_entity LegalEntity? @relation("BudgetPoolLegalEntity", fields: [legal_entity_id], references: [id], onDelete: SetNull)
|
||||||
|
cost_center CostCenter? @relation("BudgetPoolCostCenter", fields: [cost_center_id], references: [id], onDelete: SetNull)
|
||||||
ledger_entries BudgetLedgerEntry[]
|
ledger_entries BudgetLedgerEntry[]
|
||||||
|
|
||||||
@@index([department_id, status], map: "idx_budget_pools_department_status")
|
@@index([department_id, status], map: "idx_budget_pools_department_status")
|
||||||
|
@@index([legal_entity_id, cost_center_id, status], map: "idx_budget_pools_scope_status")
|
||||||
@@index([status, period_start, period_end], map: "idx_budget_pools_period")
|
@@index([status, period_start, period_end], map: "idx_budget_pools_period")
|
||||||
@@map("budget_pools")
|
@@map("budget_pools")
|
||||||
}
|
}
|
||||||
@@ -849,6 +856,8 @@ model BudgetLedgerEntry {
|
|||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
budget_pool_id String @db.Uuid
|
budget_pool_id String @db.Uuid
|
||||||
reimbursement_id String? @db.Uuid
|
reimbursement_id String? @db.Uuid
|
||||||
|
entity_type String? @db.VarChar(32)
|
||||||
|
entity_id String? @db.Uuid
|
||||||
kind String @db.VarChar(32)
|
kind String @db.VarChar(32)
|
||||||
amount Decimal @db.Decimal(14, 2)
|
amount Decimal @db.Decimal(14, 2)
|
||||||
note String?
|
note String?
|
||||||
@@ -860,6 +869,7 @@ model BudgetLedgerEntry {
|
|||||||
|
|
||||||
@@index([budget_pool_id, created_at], map: "idx_budget_ledger_pool_created_at")
|
@@index([budget_pool_id, created_at], map: "idx_budget_ledger_pool_created_at")
|
||||||
@@index([reimbursement_id])
|
@@index([reimbursement_id])
|
||||||
|
@@index([entity_type, entity_id], map: "idx_budget_ledger_entity")
|
||||||
@@map("budget_ledger_entries")
|
@@map("budget_ledger_entries")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,6 +985,7 @@ model WorkflowInstance {
|
|||||||
entity_id String @db.Uuid
|
entity_id String @db.Uuid
|
||||||
reimbursement_id String? @db.Uuid
|
reimbursement_id String? @db.Uuid
|
||||||
leave_request_id String? @db.Uuid
|
leave_request_id String? @db.Uuid
|
||||||
|
purchase_request_id String? @db.Uuid
|
||||||
submitter_id String @db.Uuid
|
submitter_id String @db.Uuid
|
||||||
current_status WorkflowInstanceStatus @default(pending)
|
current_status WorkflowInstanceStatus @default(pending)
|
||||||
current_step_id String
|
current_step_id String
|
||||||
@@ -1003,6 +1014,7 @@ model WorkflowInstance {
|
|||||||
@@index([entity_type, entity_id])
|
@@index([entity_type, entity_id])
|
||||||
@@index([reimbursement_id])
|
@@index([reimbursement_id])
|
||||||
@@index([leave_request_id])
|
@@index([leave_request_id])
|
||||||
|
@@index([purchase_request_id])
|
||||||
@@index([current_status])
|
@@index([current_status])
|
||||||
@@index([submitter_id])
|
@@index([submitter_id])
|
||||||
@@index([context_department_id])
|
@@index([context_department_id])
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ const ENTITY_PERMISSION_MAP = {
|
|||||||
approve: "leave.approve",
|
approve: "leave.approve",
|
||||||
manage: "leave.manage",
|
manage: "leave.manage",
|
||||||
},
|
},
|
||||||
|
purchase_request: {
|
||||||
|
create: "procurement.create",
|
||||||
|
view_own: "procurement.view_own",
|
||||||
|
view_all: "procurement.view_all",
|
||||||
|
submit: "procurement.submit",
|
||||||
|
approve: "procurement.approve",
|
||||||
|
manage: "procurement.manage",
|
||||||
|
},
|
||||||
|
procurement: {
|
||||||
|
create: "procurement.create",
|
||||||
|
view_own: "procurement.view_own",
|
||||||
|
view_all: "procurement.view_all",
|
||||||
|
submit: "procurement.submit",
|
||||||
|
approve: "procurement.approve",
|
||||||
|
manage: "procurement.manage",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_PERMISSIONS = {
|
const ROLE_PERMISSIONS = {
|
||||||
|
|||||||
+207
-46
@@ -3,16 +3,26 @@ const { z } = require("zod");
|
|||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
|
const {
|
||||||
|
resolveBudgetScope,
|
||||||
|
resolveBudgetScopeType,
|
||||||
|
} = require("../services/budgetPool");
|
||||||
|
|
||||||
const BUDGET_CREATE_SCHEMA = z.object({
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
|
|
||||||
|
const BUDGET_CREATE_SCHEMA = z
|
||||||
|
.object({
|
||||||
code: z.string().trim().min(2).max(32),
|
code: z.string().trim().min(2).max(32),
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
departmentId: z.string().uuid().nullable().optional(),
|
departmentId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
legalEntityId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
costCenterId: UUID_SCHEMA.nullable().optional(),
|
||||||
periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/),
|
allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/),
|
||||||
warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(),
|
warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(),
|
||||||
});
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const BUDGET_PATCH_SCHEMA = z
|
const BUDGET_PATCH_SCHEMA = z
|
||||||
.object({
|
.object({
|
||||||
@@ -25,11 +35,46 @@ const BUDGET_PATCH_SCHEMA = z
|
|||||||
|
|
||||||
const BUDGET_LIST_QUERY_SCHEMA = z.object({
|
const BUDGET_LIST_QUERY_SCHEMA = z.object({
|
||||||
status: z.enum(["active", "archived"]).optional(),
|
status: z.enum(["active", "archived"]).optional(),
|
||||||
departmentId: z.string().uuid().optional(),
|
departmentId: UUID_SCHEMA.optional(),
|
||||||
|
legalEntityId: UUID_SCHEMA.optional(),
|
||||||
|
costCenterId: UUID_SCHEMA.optional(),
|
||||||
page: z.coerce.number().int().min(1).optional(),
|
page: z.coerce.number().int().min(1).optional(),
|
||||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const BUDGET_RELATION_SELECT = {
|
||||||
|
department: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legal_entity: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cost_center: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
legal_entity_id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildValidationError(details, statusCode = 400) {
|
||||||
|
return new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toDate(value) {
|
function toDate(value) {
|
||||||
return new Date(`${value}T00:00:00.000Z`);
|
return new Date(`${value}T00:00:00.000Z`);
|
||||||
}
|
}
|
||||||
@@ -46,12 +91,37 @@ function mapBudget(item) {
|
|||||||
const allocated = Number(item.allocated_amount ?? 0);
|
const allocated = Number(item.allocated_amount ?? 0);
|
||||||
const used = Number(item.used_amount ?? 0);
|
const used = Number(item.used_amount ?? 0);
|
||||||
const remaining = allocated - used;
|
const remaining = allocated - used;
|
||||||
|
const scopeType = resolveBudgetScopeType(item);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
code: item.code,
|
code: item.code,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
|
scopeType,
|
||||||
departmentId: item.department_id ?? null,
|
departmentId: item.department_id ?? null,
|
||||||
|
legalEntityId: item.legal_entity_id ?? null,
|
||||||
|
costCenterId: item.cost_center_id ?? null,
|
||||||
|
department: item.department
|
||||||
|
? {
|
||||||
|
id: item.department.id,
|
||||||
|
name: item.department.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
legalEntity: item.legal_entity
|
||||||
|
? {
|
||||||
|
id: item.legal_entity.id,
|
||||||
|
code: item.legal_entity.code,
|
||||||
|
name: item.legal_entity.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
costCenter: item.cost_center
|
||||||
|
? {
|
||||||
|
id: item.cost_center.id,
|
||||||
|
code: item.cost_center.code,
|
||||||
|
name: item.cost_center.name,
|
||||||
|
legalEntityId: item.cost_center.legal_entity_id,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
periodStart: item.period_start?.toISOString?.()?.slice(0, 10) ?? null,
|
periodStart: item.period_start?.toISOString?.()?.slice(0, 10) ?? null,
|
||||||
periodEnd: item.period_end?.toISOString?.()?.slice(0, 10) ?? null,
|
periodEnd: item.period_end?.toISOString?.()?.slice(0, 10) ?? null,
|
||||||
@@ -66,19 +136,105 @@ function mapBudget(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseBudgetId(value) {
|
function parseBudgetId(value) {
|
||||||
const parsed = z.string().uuid().safeParse(value);
|
const parsed = UUID_SCHEMA.safeParse(value);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw buildValidationError({ id: "invalid_uuid" });
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { id: "invalid_uuid" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed.data;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureDepartmentAvailable(tx, departmentId) {
|
||||||
|
if (!departmentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const department = await tx.department.findUnique({
|
||||||
|
where: { id: departmentId },
|
||||||
|
select: { id: true, name: true, archived_at: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!department) {
|
||||||
|
throw buildValidationError({ departmentId: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (department.archived_at) {
|
||||||
|
throw buildValidationError({ departmentId: "archived" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return department;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLegalEntityAvailable(tx, legalEntityId) {
|
||||||
|
if (!legalEntityId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legalEntity = await tx.legalEntity.findUnique({
|
||||||
|
where: { id: legalEntityId },
|
||||||
|
select: { id: true, code: true, name: true, archived_at: true, enabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!legalEntity) {
|
||||||
|
throw buildValidationError({ legalEntityId: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legalEntity.archived_at || !legalEntity.enabled) {
|
||||||
|
throw buildValidationError({ legalEntityId: "disabled_or_archived" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return legalEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCostCenterAvailable(tx, { costCenterId, legalEntityId }) {
|
||||||
|
if (!costCenterId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const costCenter = await tx.costCenter.findUnique({
|
||||||
|
where: { id: costCenterId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
legal_entity_id: true,
|
||||||
|
archived_at: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!costCenter) {
|
||||||
|
throw buildValidationError({ costCenterId: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (costCenter.archived_at || !costCenter.enabled) {
|
||||||
|
throw buildValidationError({ costCenterId: "disabled_or_archived" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legalEntityId && costCenter.legal_entity_id !== legalEntityId) {
|
||||||
|
throw buildValidationError({ costCenterId: "legalEntityId_mismatch" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return costCenter;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBudgetPoolOrThrow(tx, budgetId) {
|
||||||
|
const budget = await tx.budgetPool.findUnique({
|
||||||
|
where: { id: budgetId },
|
||||||
|
include: BUDGET_RELATION_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
async function budgetsRoutes(fastify) {
|
async function budgetsRoutes(fastify) {
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/budgets",
|
"/budgets",
|
||||||
@@ -88,12 +244,7 @@ async function budgetsRoutes(fastify) {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
|
const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw buildValidationError(parsed.error.flatten());
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: parsed.error.flatten(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = parsed.data;
|
const query = parsed.data;
|
||||||
@@ -104,6 +255,8 @@ async function budgetsRoutes(fastify) {
|
|||||||
const where = {
|
const where = {
|
||||||
...(query.status ? { status: query.status } : {}),
|
...(query.status ? { status: query.status } : {}),
|
||||||
...(query.departmentId ? { department_id: query.departmentId } : {}),
|
...(query.departmentId ? { department_id: query.departmentId } : {}),
|
||||||
|
...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}),
|
||||||
|
...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@@ -112,6 +265,7 @@ async function budgetsRoutes(fastify) {
|
|||||||
orderBy: [{ period_start: "desc" }, { created_at: "desc" }],
|
orderBy: [{ period_start: "desc" }, { created_at: "desc" }],
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
include: BUDGET_RELATION_SELECT,
|
||||||
}),
|
}),
|
||||||
fastify.prisma.budgetPool.count({ where }),
|
fastify.prisma.budgetPool.count({ where }),
|
||||||
]);
|
]);
|
||||||
@@ -133,31 +287,36 @@ async function budgetsRoutes(fastify) {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {});
|
const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw buildValidationError(parsed.error.flatten());
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: parsed.error.flatten(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
const start = toDate(body.periodStart);
|
const start = toDate(body.periodStart);
|
||||||
const end = toDate(body.periodEnd);
|
const end = toDate(body.periodEnd);
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
throw new AppError({
|
throw buildValidationError({ periodEnd: "must_be_after_periodStart" });
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { periodEnd: "must_be_after_periodStart" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scope = resolveBudgetScope({
|
||||||
|
departmentId: body.departmentId ?? null,
|
||||||
|
legalEntityId: body.legalEntityId ?? null,
|
||||||
|
costCenterId: body.costCenterId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureDepartmentAvailable(fastify.prisma, scope.departmentId);
|
||||||
|
await ensureLegalEntityAvailable(fastify.prisma, scope.legalEntityId);
|
||||||
|
await ensureCostCenterAvailable(fastify.prisma, {
|
||||||
|
costCenterId: scope.costCenterId,
|
||||||
|
legalEntityId: scope.legalEntityId,
|
||||||
|
});
|
||||||
|
|
||||||
const created = await fastify.prisma.budgetPool.create({
|
const created = await fastify.prisma.budgetPool.create({
|
||||||
data: {
|
data: {
|
||||||
code: body.code.toUpperCase(),
|
code: body.code.trim().toUpperCase(),
|
||||||
name: body.name,
|
name: body.name.trim(),
|
||||||
department_id: body.departmentId ?? null,
|
department_id: scope.departmentId,
|
||||||
|
legal_entity_id: scope.legalEntityId,
|
||||||
|
cost_center_id: scope.costCenterId,
|
||||||
status: "active",
|
status: "active",
|
||||||
period_start: start,
|
period_start: start,
|
||||||
period_end: end,
|
period_end: end,
|
||||||
@@ -167,6 +326,7 @@ async function budgetsRoutes(fastify) {
|
|||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
|
include: BUDGET_RELATION_SELECT,
|
||||||
});
|
});
|
||||||
|
|
||||||
await fastify.prisma.auditLog.create({
|
await fastify.prisma.auditLog.create({
|
||||||
@@ -177,6 +337,10 @@ async function budgetsRoutes(fastify) {
|
|||||||
targetId: created.id,
|
targetId: created.id,
|
||||||
extra: {
|
extra: {
|
||||||
code: created.code,
|
code: created.code,
|
||||||
|
scopeType: scope.scopeType,
|
||||||
|
departmentId: scope.departmentId,
|
||||||
|
legalEntityId: scope.legalEntityId,
|
||||||
|
costCenterId: scope.costCenterId,
|
||||||
allocatedAmount: created.allocated_amount,
|
allocatedAmount: created.allocated_amount,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -195,36 +359,30 @@ async function budgetsRoutes(fastify) {
|
|||||||
const budgetId = parseBudgetId(request.params?.id);
|
const budgetId = parseBudgetId(request.params?.id);
|
||||||
const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.body ?? {});
|
const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.body ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw buildValidationError(parsed.error.flatten());
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: parsed.error.flatten(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(parsed.data).length) {
|
if (!Object.keys(parsed.data).length) {
|
||||||
throw new AppError({
|
throw buildValidationError({ body: "empty_patch" });
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { body: "empty_patch" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await fetchBudgetPoolOrThrow(fastify.prisma, budgetId);
|
||||||
|
|
||||||
const patch = parsed.data;
|
const patch = parsed.data;
|
||||||
const updated = await fastify.prisma.budgetPool.update({
|
const updated = await fastify.prisma.budgetPool.update({
|
||||||
where: { id: budgetId },
|
where: { id: budgetId },
|
||||||
data: {
|
data: {
|
||||||
...(patch.name != null ? { name: patch.name } : {}),
|
...(patch.name != null ? { name: patch.name.trim() } : {}),
|
||||||
...(patch.allocatedAmount != null ? { allocated_amount: patch.allocatedAmount } : {}),
|
...(patch.allocatedAmount != null ? { allocated_amount: patch.allocatedAmount } : {}),
|
||||||
...(patch.warningThresholdPercent != null
|
...(patch.warningThresholdPercent != null
|
||||||
? { warning_threshold_percent: patch.warningThresholdPercent }
|
? { warning_threshold_percent: patch.warningThresholdPercent }
|
||||||
: {}),
|
: {}),
|
||||||
...(patch.status ? { status: patch.status } : {}),
|
...(patch.status ? { status: patch.status } : {}),
|
||||||
...(patch.status === "archived" ? { archived_at: new Date() } : {}),
|
...(patch.status === "archived" ? { archived_at: new Date() } : {}),
|
||||||
|
...(patch.status === "active" ? { archived_at: null } : {}),
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
|
include: BUDGET_RELATION_SELECT,
|
||||||
});
|
});
|
||||||
|
|
||||||
await fastify.prisma.auditLog.create({
|
await fastify.prisma.auditLog.create({
|
||||||
@@ -250,6 +408,7 @@ async function budgetsRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const budgetId = parseBudgetId(request.params?.id);
|
const budgetId = parseBudgetId(request.params?.id);
|
||||||
|
const current = await fetchBudgetPoolOrThrow(fastify.prisma, budgetId);
|
||||||
|
|
||||||
const updated = await fastify.prisma.budgetPool.update({
|
const updated = await fastify.prisma.budgetPool.update({
|
||||||
where: { id: budgetId },
|
where: { id: budgetId },
|
||||||
@@ -258,6 +417,7 @@ async function budgetsRoutes(fastify) {
|
|||||||
archived_at: new Date(),
|
archived_at: new Date(),
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
|
include: BUDGET_RELATION_SELECT,
|
||||||
});
|
});
|
||||||
|
|
||||||
await fastify.prisma.auditLog.create({
|
await fastify.prisma.auditLog.create({
|
||||||
@@ -267,7 +427,8 @@ async function budgetsRoutes(fastify) {
|
|||||||
targetType: "budget_pool",
|
targetType: "budget_pool",
|
||||||
targetId: budgetId,
|
targetId: budgetId,
|
||||||
extra: {
|
extra: {
|
||||||
code: updated.code,
|
code: current.code,
|
||||||
|
scopeType: resolveBudgetScopeType(current),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-45
@@ -2,11 +2,19 @@ const { z } = require("zod");
|
|||||||
|
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
|
const {
|
||||||
|
normalizeDocumentType,
|
||||||
|
resolveDocumentSequenceConfig,
|
||||||
|
} = require("../services/documentSequence");
|
||||||
|
|
||||||
const ENABLED_QUERY_SCHEMA = z.object({
|
const ENABLED_QUERY_SCHEMA = z.object({
|
||||||
enabled: z.enum(["true", "false", "1", "0"]).optional(),
|
enabled: z.enum(["true", "false", "1", "0"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_PARAM_SCHEMA = z.object({
|
||||||
|
documentType: z.string().trim().min(1).max(64).regex(/^[a-z_]+$/),
|
||||||
|
});
|
||||||
|
|
||||||
function parseEnabledQuery(query) {
|
function parseEnabledQuery(query) {
|
||||||
const parsed = ENABLED_QUERY_SCHEMA.safeParse(query ?? {});
|
const parsed = ENABLED_QUERY_SCHEMA.safeParse(query ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -31,6 +39,20 @@ function parseEnabledQuery(query) {
|
|||||||
return { enabled };
|
return { enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDocumentTypeParams(params) {
|
||||||
|
const parsed = DOCUMENT_TYPE_PARAM_SCHEMA.safeParse(params ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeDocumentType(parsed.data.documentType);
|
||||||
|
}
|
||||||
|
|
||||||
function mapApprovalRule(item) {
|
function mapApprovalRule(item) {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -47,39 +69,30 @@ function mapApprovalRule(item) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapDocumentSequence(item, exists) {
|
function mapDocumentSequence(config) {
|
||||||
const fallback = {
|
|
||||||
documentType: "reimbursement",
|
|
||||||
prefix: "BX",
|
|
||||||
dateFormat: "YYYYMMDD",
|
|
||||||
separator: "-",
|
|
||||||
numberLength: 4,
|
|
||||||
resetPeriod: "none",
|
|
||||||
currentNumber: 0,
|
|
||||||
lastResetDate: null,
|
|
||||||
updatedAt: null,
|
|
||||||
updatedBy: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return { exists, ...fallback };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exists,
|
exists: Boolean(config.exists),
|
||||||
documentType: item.document_type,
|
source: config.source,
|
||||||
prefix: item.prefix,
|
documentType: config.documentType,
|
||||||
dateFormat: item.date_format,
|
documentCategoryCode: config.documentCategoryCode ?? null,
|
||||||
separator: item.separator,
|
prefix: config.prefix,
|
||||||
numberLength: item.number_length,
|
dateFormat: config.dateFormat,
|
||||||
resetPeriod: item.reset_period,
|
separator: config.separator,
|
||||||
currentNumber: item.current_number,
|
numberLength: config.numberLength,
|
||||||
lastResetDate: item.last_reset_date?.toISOString?.()?.slice(0, 10) ?? null,
|
resetPeriod: config.resetPeriod,
|
||||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
currentNumber: config.currentNumber,
|
||||||
updatedBy: item.updated_by ?? null,
|
lastResetDate: config.lastResetDate ?? null,
|
||||||
|
updatedAt: config.updatedAt ?? null,
|
||||||
|
updatedBy: config.updatedBy ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDocumentSequenceConfig(fastify, documentType) {
|
||||||
|
return resolveDocumentSequenceConfig(fastify.prisma, {
|
||||||
|
documentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function configReadRoutes(fastify) {
|
async function configReadRoutes(fastify) {
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/approval-rules",
|
"/approval-rules",
|
||||||
@@ -122,26 +135,22 @@ async function configReadRoutes(fastify) {
|
|||||||
preHandler: fastify.requirePermission("system.settings"),
|
preHandler: fastify.requirePermission("system.settings"),
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const sequence = await fastify.prisma.documentSequence.findUnique({
|
const config = await loadDocumentSequenceConfig(fastify, "reimbursement");
|
||||||
where: { document_type: "reimbursement" },
|
return sendData(reply, request, mapDocumentSequence(config));
|
||||||
select: {
|
|
||||||
document_type: true,
|
|
||||||
prefix: true,
|
|
||||||
date_format: true,
|
|
||||||
separator: true,
|
|
||||||
number_length: true,
|
|
||||||
reset_period: true,
|
|
||||||
current_number: true,
|
|
||||||
last_reset_date: true,
|
|
||||||
updated_at: true,
|
|
||||||
updated_by: true,
|
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
return sendData(reply, request, mapDocumentSequence(sequence, Boolean(sequence)));
|
fastify.get(
|
||||||
|
"/document-sequences/:documentType",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.settings"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const documentType = parseDocumentTypeParams(request.params);
|
||||||
|
const config = await loadDocumentSequenceConfig(fastify, documentType);
|
||||||
|
return sendData(reply, request, mapDocumentSequence(config));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = configReadRoutes;
|
module.exports = configReadRoutes;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const { z } = require("zod");
|
|||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
||||||
const {
|
const {
|
||||||
createNotifications,
|
createNotifications,
|
||||||
@@ -12,6 +12,8 @@ const { sendNotificationEmailSafe } = require("../services/email");
|
|||||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||||
const { buildErpPayload } = require("../services/integrations/erp");
|
const { buildErpPayload } = require("../services/integrations/erp");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||||
|
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||||
const {
|
const {
|
||||||
buildWorkflowStepsFromApprovalRule,
|
buildWorkflowStepsFromApprovalRule,
|
||||||
resolveActionableStep,
|
resolveActionableStep,
|
||||||
@@ -90,12 +92,6 @@ function ensureApplicantOrAdmin(request, reimbursement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFormNumber(sequence, timezone) {
|
|
||||||
const datePart = compactDateString(timezone);
|
|
||||||
const serial = String(sequence.current_number).padStart(sequence.number_length, "0");
|
|
||||||
return `${sequence.prefix}${sequence.separator}${datePart}${sequence.separator}${serial}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWithdrawBody(body) {
|
function parseWithdrawBody(body) {
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
comment: z.string().trim().min(1).max(500).optional(),
|
comment: z.string().trim().min(1).max(500).optional(),
|
||||||
@@ -350,14 +346,9 @@ async function loadApprovalRuleForAmount(tx, totalAmount) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveApplicableBudgetPool(tx, reimbursement) {
|
async function resolveApplicableBudgetPool(tx, reimbursement) {
|
||||||
return tx.budgetPool.findFirst({
|
return resolveApplicableBudgetPoolByContext(tx, {
|
||||||
where: {
|
departmentId: reimbursement.department_id,
|
||||||
status: "active",
|
effectiveDate: reimbursement.reimbursement_date,
|
||||||
OR: [{ department_id: reimbursement.department_id }, { department_id: null }],
|
|
||||||
period_start: { lte: reimbursement.reimbursement_date },
|
|
||||||
period_end: { gte: reimbursement.reimbursement_date },
|
|
||||||
},
|
|
||||||
orderBy: [{ department_id: "desc" }, { created_at: "asc" }],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,7 +1195,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
const reimbursementRows = await tx.$queryRaw`
|
||||||
SELECT id, owner_id, submitter_id, department_id, status, form_number, total_amount
|
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount
|
||||||
FROM "reimbursement_forms"
|
FROM "reimbursement_forms"
|
||||||
WHERE id = ${reimbursementId}::uuid
|
WHERE id = ${reimbursementId}::uuid
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -1371,31 +1362,13 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
let formNumber = reimbursement.form_number;
|
let formNumber = reimbursement.form_number;
|
||||||
if (!formNumber) {
|
if (!formNumber) {
|
||||||
const sequence = await tx.documentSequence.upsert({
|
const issued = await issueDocumentNumber(tx, {
|
||||||
where: { document_type: "reimbursement" },
|
documentType: "reimbursement",
|
||||||
update: {
|
actorId: request.employee.id,
|
||||||
current_number: { increment: 1 },
|
timeZone: fastify.config.appTimezone,
|
||||||
updated_by: request.employee.id,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
document_type: "reimbursement",
|
|
||||||
prefix: "BX",
|
|
||||||
date_format: "YYYYMMDD",
|
|
||||||
separator: "-",
|
|
||||||
number_length: 4,
|
|
||||||
reset_period: "none",
|
|
||||||
current_number: 1,
|
|
||||||
updated_by: request.employee.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
prefix: true,
|
|
||||||
separator: true,
|
|
||||||
number_length: true,
|
|
||||||
current_number: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
formNumber = formatFormNumber(sequence, fastify.config.appTimezone);
|
formNumber = issued.documentNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.reimbursementForm.update({
|
await tx.reimbursementForm.update({
|
||||||
|
|||||||
+127
-26
@@ -335,6 +335,77 @@ async function loadReimbursementNotifyContext(tx, reimbursementId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWorkflowEntityMeta(entityType, entityRecord, entityId) {
|
||||||
|
const normalizedEntityType = entityType === "procurement" ? "purchase_request" : entityType;
|
||||||
|
|
||||||
|
if (normalizedEntityType === "reimbursement") {
|
||||||
|
return {
|
||||||
|
label: "报销单",
|
||||||
|
reference: entityRecord?.form_number ?? entityId,
|
||||||
|
targetType: "reimbursement_form",
|
||||||
|
detailPath: `/reimbursements/${entityId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEntityType === "leave") {
|
||||||
|
return {
|
||||||
|
label: "请假单",
|
||||||
|
reference: entityId,
|
||||||
|
targetType: "leave_request",
|
||||||
|
detailPath: "/leave-requests",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEntityType === "purchase_request") {
|
||||||
|
return {
|
||||||
|
label: "采购申请",
|
||||||
|
reference: entityRecord?.request_no ?? entityId,
|
||||||
|
targetType: "purchase_request",
|
||||||
|
detailPath: "/purchase-requests",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "审批单",
|
||||||
|
reference: entityId,
|
||||||
|
targetType: normalizedEntityType,
|
||||||
|
detailPath: `/workflow/tasks/${entityId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPendingTaskCopy(entityMeta, { transfer = false } = {}) {
|
||||||
|
return {
|
||||||
|
title: `【待办】${entityMeta.label} ${entityMeta.reference} ${transfer ? "转审待处理" : "待审批"}`,
|
||||||
|
body: transfer
|
||||||
|
? `${entityMeta.label} ${entityMeta.reference} 已转审给你,请及时处理。`
|
||||||
|
: `${entityMeta.label} ${entityMeta.reference} 已流转到你,请及时处理。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApprovedCopy(entityMeta) {
|
||||||
|
return {
|
||||||
|
title: `【已批准】${entityMeta.label} ${entityMeta.reference}`,
|
||||||
|
body:
|
||||||
|
entityMeta.targetType === "reimbursement_form"
|
||||||
|
? `${entityMeta.label} ${entityMeta.reference} 已审批通过,等待财务付款。`
|
||||||
|
: `${entityMeta.label} ${entityMeta.reference} 已审批通过。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRejectedCopy(entityMeta, comment) {
|
||||||
|
return {
|
||||||
|
title: `【已驳回】${entityMeta.label} ${entityMeta.reference}`,
|
||||||
|
body: `${entityMeta.label} ${entityMeta.reference} 已被驳回:${comment}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReturnedCopy(entityMeta, comment) {
|
||||||
|
return {
|
||||||
|
title: `【已退回】${entityMeta.label} ${entityMeta.reference}`,
|
||||||
|
body: `${entityMeta.label} ${entityMeta.reference} 已退回:${comment}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
|
async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
|
||||||
for (const skipped of skippedSteps) {
|
for (const skipped of skippedSteps) {
|
||||||
await tx.workflowHistory.create({
|
await tx.workflowHistory.create({
|
||||||
@@ -892,6 +963,12 @@ async function workflowRoutes(fastify) {
|
|||||||
tx,
|
tx,
|
||||||
workflowInstance.entity_id,
|
workflowInstance.entity_id,
|
||||||
);
|
);
|
||||||
|
const entityMeta = getWorkflowEntityMeta(
|
||||||
|
workflowInstance.entity_type,
|
||||||
|
reimbursement,
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const pendingCopy = buildPendingTaskCopy(entityMeta);
|
||||||
const recipients = await resolveRecipientsForAssignee(tx, {
|
const recipients = await resolveRecipientsForAssignee(tx, {
|
||||||
assigneeId: actionable.assigneeId,
|
assigneeId: actionable.assigneeId,
|
||||||
assigneeRole: actionable.assigneeRole,
|
assigneeRole: actionable.assigneeRole,
|
||||||
@@ -900,8 +977,8 @@ async function workflowRoutes(fastify) {
|
|||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
type: "workflow.task_assigned",
|
type: "workflow.task_assigned",
|
||||||
title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
|
title: pendingCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
|
body: pendingCopy.body,
|
||||||
targetType: "workflow_task",
|
targetType: "workflow_task",
|
||||||
targetId: nextTask.id,
|
targetId: nextTask.id,
|
||||||
});
|
});
|
||||||
@@ -909,8 +986,8 @@ async function workflowRoutes(fastify) {
|
|||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "workflow.task_assigned",
|
type: "workflow.task_assigned",
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
|
subject: pendingCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
|
body: pendingCopy.body,
|
||||||
targetPath: `/workflow/tasks/${nextTask.id}`,
|
targetPath: `/workflow/tasks/${nextTask.id}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1013,6 +1090,12 @@ async function workflowRoutes(fastify) {
|
|||||||
if (workflowInstance.entity_type === "reimbursement") {
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
try {
|
try {
|
||||||
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
|
const entityMeta = getWorkflowEntityMeta(
|
||||||
|
workflowInstance.entity_type,
|
||||||
|
reimbursement,
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const approvedCopy = buildApprovedCopy(entityMeta);
|
||||||
const recipients = Array.from(
|
const recipients = Array.from(
|
||||||
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
);
|
);
|
||||||
@@ -1020,18 +1103,18 @@ async function workflowRoutes(fastify) {
|
|||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
type: "reimbursement.approved",
|
type: "reimbursement.approved",
|
||||||
title: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
title: approvedCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
|
body: approvedCopy.body,
|
||||||
targetType: "reimbursement_form",
|
targetType: entityMeta.targetType,
|
||||||
targetId: workflowInstance.entity_id,
|
targetId: workflowInstance.entity_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "reimbursement.approved",
|
type: "reimbursement.approved",
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
subject: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
subject: approvedCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
|
body: approvedCopy.body,
|
||||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
targetPath: entityMeta.detailPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
@@ -1179,6 +1262,12 @@ async function workflowRoutes(fastify) {
|
|||||||
if (workflowInstance.entity_type === "reimbursement") {
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
try {
|
try {
|
||||||
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
|
const entityMeta = getWorkflowEntityMeta(
|
||||||
|
workflowInstance.entity_type,
|
||||||
|
reimbursement,
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const rejectedCopy = buildRejectedCopy(entityMeta, comment);
|
||||||
const recipients = Array.from(
|
const recipients = Array.from(
|
||||||
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
);
|
);
|
||||||
@@ -1186,18 +1275,18 @@ async function workflowRoutes(fastify) {
|
|||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
type: "reimbursement.rejected",
|
type: "reimbursement.rejected",
|
||||||
title: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
title: rejectedCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
|
body: rejectedCopy.body,
|
||||||
targetType: "reimbursement_form",
|
targetType: entityMeta.targetType,
|
||||||
targetId: workflowInstance.entity_id,
|
targetId: workflowInstance.entity_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "reimbursement.rejected",
|
type: "reimbursement.rejected",
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
subject: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
subject: rejectedCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
|
body: rejectedCopy.body,
|
||||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
targetPath: entityMeta.detailPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
@@ -1344,6 +1433,12 @@ async function workflowRoutes(fastify) {
|
|||||||
if (workflowInstance.entity_type === "reimbursement") {
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
try {
|
try {
|
||||||
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
|
const entityMeta = getWorkflowEntityMeta(
|
||||||
|
workflowInstance.entity_type,
|
||||||
|
reimbursement,
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const returnedCopy = buildReturnedCopy(entityMeta, comment);
|
||||||
const recipients = Array.from(
|
const recipients = Array.from(
|
||||||
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
);
|
);
|
||||||
@@ -1351,18 +1446,18 @@ async function workflowRoutes(fastify) {
|
|||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
type: "reimbursement.returned",
|
type: "reimbursement.returned",
|
||||||
title: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
title: returnedCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
|
body: returnedCopy.body,
|
||||||
targetType: "reimbursement_form",
|
targetType: entityMeta.targetType,
|
||||||
targetId: workflowInstance.entity_id,
|
targetId: workflowInstance.entity_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "reimbursement.returned",
|
type: "reimbursement.returned",
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
subject: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
subject: returnedCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
|
body: returnedCopy.body,
|
||||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
targetPath: entityMeta.detailPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
@@ -1475,11 +1570,17 @@ async function workflowRoutes(fastify) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
|
const entityMeta = getWorkflowEntityMeta(
|
||||||
|
workflowInstance.entity_type,
|
||||||
|
reimbursement,
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const pendingCopy = buildPendingTaskCopy(entityMeta, { transfer: true });
|
||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: [toApproverId],
|
recipientEmployeeIds: [toApproverId],
|
||||||
type: "workflow.task_assigned",
|
type: "workflow.task_assigned",
|
||||||
title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 转审待处理`,
|
title: pendingCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已转审给你,请及时处理。`,
|
body: pendingCopy.body,
|
||||||
targetType: "workflow_task",
|
targetType: "workflow_task",
|
||||||
targetId: taskId,
|
targetId: taskId,
|
||||||
});
|
});
|
||||||
@@ -1487,8 +1588,8 @@ async function workflowRoutes(fastify) {
|
|||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "workflow.task_assigned",
|
type: "workflow.task_assigned",
|
||||||
recipientEmployeeIds: [toApproverId],
|
recipientEmployeeIds: [toApproverId],
|
||||||
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 转审待处理`,
|
subject: pendingCopy.title,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已转审给你,请及时处理。`,
|
body: pendingCopy.body,
|
||||||
targetPath: `/workflow/tasks/${taskId}`,
|
targetPath: `/workflow/tasks/${taskId}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
|
||||||
|
const BUDGET_SCOPE_TYPES = Object.freeze({
|
||||||
|
global: "global",
|
||||||
|
departmentLegacy: "department_legacy",
|
||||||
|
costCenterScoped: "cost_center_scoped",
|
||||||
|
});
|
||||||
|
|
||||||
|
const BUDGET_SCOPE_PRIORITY = [
|
||||||
|
BUDGET_SCOPE_TYPES.costCenterScoped,
|
||||||
|
BUDGET_SCOPE_TYPES.departmentLegacy,
|
||||||
|
BUDGET_SCOPE_TYPES.global,
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildValidationError(details, statusCode = 400) {
|
||||||
|
return new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBudgetScope({ departmentId = null, legalEntityId = null, costCenterId = null } = {}) {
|
||||||
|
const hasDepartment = Boolean(departmentId);
|
||||||
|
const hasLegalEntity = Boolean(legalEntityId);
|
||||||
|
const hasCostCenter = Boolean(costCenterId);
|
||||||
|
|
||||||
|
if (hasCostCenter && !hasLegalEntity) {
|
||||||
|
throw buildValidationError({ costCenterId: "legalEntityId_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDepartment && (hasLegalEntity || hasCostCenter)) {
|
||||||
|
throw buildValidationError({ scope: "mixed_dimensions_not_allowed" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLegalEntity && !hasCostCenter) {
|
||||||
|
throw buildValidationError({ costCenterId: "required_with_legalEntityId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLegalEntity && hasCostCenter) {
|
||||||
|
return {
|
||||||
|
scopeType: BUDGET_SCOPE_TYPES.costCenterScoped,
|
||||||
|
departmentId: null,
|
||||||
|
legalEntityId,
|
||||||
|
costCenterId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDepartment) {
|
||||||
|
return {
|
||||||
|
scopeType: BUDGET_SCOPE_TYPES.departmentLegacy,
|
||||||
|
departmentId,
|
||||||
|
legalEntityId: null,
|
||||||
|
costCenterId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scopeType: BUDGET_SCOPE_TYPES.global,
|
||||||
|
departmentId: null,
|
||||||
|
legalEntityId: null,
|
||||||
|
costCenterId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBudgetScopeType(record) {
|
||||||
|
const hasDepartment = Boolean(record?.department_id);
|
||||||
|
const hasLegalEntity = Boolean(record?.legal_entity_id);
|
||||||
|
const hasCostCenter = Boolean(record?.cost_center_id);
|
||||||
|
|
||||||
|
if (hasLegalEntity && hasCostCenter && !hasDepartment) {
|
||||||
|
return BUDGET_SCOPE_TYPES.costCenterScoped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDepartment && !hasLegalEntity && !hasCostCenter) {
|
||||||
|
return BUDGET_SCOPE_TYPES.departmentLegacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDepartment && !hasLegalEntity && !hasCostCenter) {
|
||||||
|
return BUDGET_SCOPE_TYPES.global;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "invalid";
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesBudgetPoolContext(record, context) {
|
||||||
|
const scopeType = resolveBudgetScopeType(record);
|
||||||
|
if (scopeType === BUDGET_SCOPE_TYPES.costCenterScoped) {
|
||||||
|
return (
|
||||||
|
Boolean(context.legalEntityId) &&
|
||||||
|
Boolean(context.costCenterId) &&
|
||||||
|
record.legal_entity_id === context.legalEntityId &&
|
||||||
|
record.cost_center_id === context.costCenterId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeType === BUDGET_SCOPE_TYPES.departmentLegacy) {
|
||||||
|
return Boolean(context.departmentId) && record.department_id === context.departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeType === BUDGET_SCOPE_TYPES.global) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBudgetContext({ departmentId = null, legalEntityId = null, costCenterId = null } = {}) {
|
||||||
|
if (costCenterId && !legalEntityId) {
|
||||||
|
throw buildValidationError({ costCenterId: "legalEntityId_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
departmentId,
|
||||||
|
legalEntityId,
|
||||||
|
costCenterId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBudgetPoolCandidate(records, context = {}) {
|
||||||
|
const normalizedContext = normalizeBudgetContext(context);
|
||||||
|
|
||||||
|
for (const scopeType of BUDGET_SCOPE_PRIORITY) {
|
||||||
|
const matches = (records ?? []).filter(
|
||||||
|
(record) => resolveBudgetScopeType(record) === scopeType && matchesBudgetPoolContext(record, normalizedContext),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matches.length === 1) {
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 1) {
|
||||||
|
if (scopeType === BUDGET_SCOPE_TYPES.costCenterScoped) {
|
||||||
|
throw buildValidationError({ budget: "budget_pool_ambiguous", scopeType }, 409);
|
||||||
|
}
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveApplicableBudgetPoolByContext(
|
||||||
|
tx,
|
||||||
|
{ departmentId = null, legalEntityId = null, costCenterId = null, effectiveDate },
|
||||||
|
) {
|
||||||
|
if (!(effectiveDate instanceof Date) || Number.isNaN(effectiveDate.getTime())) {
|
||||||
|
throw buildValidationError({ effectiveDate: "invalid_date" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await tx.budgetPool.findMany({
|
||||||
|
where: {
|
||||||
|
status: "active",
|
||||||
|
archived_at: null,
|
||||||
|
period_start: { lte: effectiveDate },
|
||||||
|
period_end: { gte: effectiveDate },
|
||||||
|
},
|
||||||
|
orderBy: [{ created_at: "asc" }, { code: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return selectBudgetPoolCandidate(records, {
|
||||||
|
departmentId,
|
||||||
|
legalEntityId,
|
||||||
|
costCenterId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BUDGET_SCOPE_TYPES,
|
||||||
|
resolveBudgetScope,
|
||||||
|
resolveBudgetScopeType,
|
||||||
|
selectBudgetPoolCandidate,
|
||||||
|
resolveApplicableBudgetPoolByContext,
|
||||||
|
};
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
const { compactDateString } = require("../utils/date");
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_ALIASES = {
|
||||||
|
expense: "reimbursement",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_CONFIG = {
|
||||||
|
reimbursement: {
|
||||||
|
defaultCategoryCode: "reimbursement_general",
|
||||||
|
preferLegacySequence: true,
|
||||||
|
fallback: {
|
||||||
|
prefix: "BX",
|
||||||
|
dateFormat: "YYYYMMDD",
|
||||||
|
separator: "-",
|
||||||
|
numberLength: 4,
|
||||||
|
resetPeriod: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
leave: {
|
||||||
|
defaultCategoryCode: "leave_request",
|
||||||
|
preferLegacySequence: false,
|
||||||
|
fallback: {
|
||||||
|
prefix: "JQ",
|
||||||
|
dateFormat: "YYYYMMDD",
|
||||||
|
separator: "-",
|
||||||
|
numberLength: 4,
|
||||||
|
resetPeriod: "yearly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
purchase_request: {
|
||||||
|
defaultCategoryCode: "purchase_general",
|
||||||
|
preferLegacySequence: false,
|
||||||
|
fallback: {
|
||||||
|
prefix: "CG",
|
||||||
|
dateFormat: "YYYYMMDD",
|
||||||
|
separator: "-",
|
||||||
|
numberLength: 4,
|
||||||
|
resetPeriod: "yearly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
asset_card: {
|
||||||
|
defaultCategoryCode: "asset_it",
|
||||||
|
preferLegacySequence: false,
|
||||||
|
fallback: {
|
||||||
|
prefix: "ZC",
|
||||||
|
dateFormat: "YYYYMMDD",
|
||||||
|
separator: "-",
|
||||||
|
numberLength: 4,
|
||||||
|
resetPeriod: "yearly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
defaultCategoryCode: "general_record",
|
||||||
|
preferLegacySequence: false,
|
||||||
|
fallback: {
|
||||||
|
prefix: "GEN",
|
||||||
|
dateFormat: "YYYYMMDD",
|
||||||
|
separator: "-",
|
||||||
|
numberLength: 4,
|
||||||
|
resetPeriod: "yearly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildValidationError(details, statusCode = 400) {
|
||||||
|
return new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentType(documentType) {
|
||||||
|
const rawValue = String(documentType ?? "").trim().toLowerCase();
|
||||||
|
const normalized = DOCUMENT_TYPE_ALIASES[rawValue] ?? rawValue;
|
||||||
|
if (!DOCUMENT_TYPE_CONFIG[normalized]) {
|
||||||
|
throw buildValidationError({ documentType: "unsupported_document_type" }, 409);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSequenceRecord(record) {
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentType: record.document_type,
|
||||||
|
prefix: record.prefix,
|
||||||
|
dateFormat: record.date_format,
|
||||||
|
separator: record.separator,
|
||||||
|
numberLength: record.number_length,
|
||||||
|
resetPeriod: record.reset_period,
|
||||||
|
currentNumber: record.current_number,
|
||||||
|
lastResetDate: record.last_reset_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||||
|
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||||
|
updatedBy: record.updated_by ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigFromFallback(documentType) {
|
||||||
|
const fallback = DOCUMENT_TYPE_CONFIG[documentType].fallback;
|
||||||
|
return {
|
||||||
|
exists: false,
|
||||||
|
source: "fallback",
|
||||||
|
documentType,
|
||||||
|
documentCategoryCode: null,
|
||||||
|
currentNumber: 0,
|
||||||
|
lastResetDate: null,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null,
|
||||||
|
...fallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigFromCategory(documentType, category, sequenceRecord) {
|
||||||
|
const fallback = DOCUMENT_TYPE_CONFIG[documentType].fallback;
|
||||||
|
const mappedSequence = mapSequenceRecord(sequenceRecord);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: Boolean(sequenceRecord),
|
||||||
|
source: "document_category",
|
||||||
|
documentType,
|
||||||
|
documentCategoryCode: category.code,
|
||||||
|
prefix: category.sequence_prefix,
|
||||||
|
dateFormat: mappedSequence?.dateFormat ?? fallback.dateFormat,
|
||||||
|
separator: mappedSequence?.separator ?? fallback.separator,
|
||||||
|
numberLength: category.number_length,
|
||||||
|
resetPeriod: category.reset_period,
|
||||||
|
currentNumber: mappedSequence?.currentNumber ?? 0,
|
||||||
|
lastResetDate: mappedSequence?.lastResetDate ?? null,
|
||||||
|
updatedAt: mappedSequence?.updatedAt ?? null,
|
||||||
|
updatedBy: mappedSequence?.updatedBy ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDocumentCategory(prisma, { documentType, documentCategoryCode }) {
|
||||||
|
const normalizedDocumentType = normalizeDocumentType(documentType);
|
||||||
|
const defaultCategoryCode = DOCUMENT_TYPE_CONFIG[normalizedDocumentType].defaultCategoryCode;
|
||||||
|
|
||||||
|
if (documentCategoryCode) {
|
||||||
|
const category = await prisma.documentCategory.findUnique({
|
||||||
|
where: { code: documentCategoryCode },
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
entity_type: true,
|
||||||
|
sequence_prefix: true,
|
||||||
|
number_length: true,
|
||||||
|
reset_period: true,
|
||||||
|
enabled: true,
|
||||||
|
archived_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw buildValidationError({ documentCategoryCode: "not_found" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.entity_type !== normalizedDocumentType) {
|
||||||
|
throw buildValidationError({ documentCategoryCode: "entity_type_mismatch" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category.enabled || category.archived_at) {
|
||||||
|
throw buildValidationError({ documentCategoryCode: "disabled_or_archived" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultCategoryCode) {
|
||||||
|
const defaultCategory = await prisma.documentCategory.findUnique({
|
||||||
|
where: { code: defaultCategoryCode },
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
entity_type: true,
|
||||||
|
sequence_prefix: true,
|
||||||
|
number_length: true,
|
||||||
|
reset_period: true,
|
||||||
|
enabled: true,
|
||||||
|
archived_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
defaultCategory &&
|
||||||
|
defaultCategory.entity_type === normalizedDocumentType &&
|
||||||
|
defaultCategory.enabled &&
|
||||||
|
!defaultCategory.archived_at
|
||||||
|
) {
|
||||||
|
return defaultCategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.documentCategory.findFirst({
|
||||||
|
where: {
|
||||||
|
entity_type: normalizedDocumentType,
|
||||||
|
enabled: true,
|
||||||
|
archived_at: null,
|
||||||
|
},
|
||||||
|
orderBy: [{ created_at: "asc" }, { code: "asc" }],
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
entity_type: true,
|
||||||
|
sequence_prefix: true,
|
||||||
|
number_length: true,
|
||||||
|
reset_period: true,
|
||||||
|
enabled: true,
|
||||||
|
archived_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDocumentSequenceConfig(prisma, { documentType, documentCategoryCode = null } = {}) {
|
||||||
|
const normalizedDocumentType = normalizeDocumentType(documentType);
|
||||||
|
const documentTypeConfig = DOCUMENT_TYPE_CONFIG[normalizedDocumentType];
|
||||||
|
|
||||||
|
const sequenceRecord = await prisma.documentSequence.findUnique({
|
||||||
|
where: { document_type: normalizedDocumentType },
|
||||||
|
select: {
|
||||||
|
document_type: true,
|
||||||
|
prefix: true,
|
||||||
|
date_format: true,
|
||||||
|
separator: true,
|
||||||
|
number_length: true,
|
||||||
|
reset_period: true,
|
||||||
|
current_number: true,
|
||||||
|
last_reset_date: true,
|
||||||
|
updated_at: true,
|
||||||
|
updated_by: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (documentTypeConfig.preferLegacySequence && !documentCategoryCode && sequenceRecord) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
source: "document_sequence",
|
||||||
|
documentCategoryCode: null,
|
||||||
|
...mapSequenceRecord(sequenceRecord),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await findDocumentCategory(prisma, {
|
||||||
|
documentType: normalizedDocumentType,
|
||||||
|
documentCategoryCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
return buildConfigFromCategory(normalizedDocumentType, category, sequenceRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sequenceRecord) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
source: "document_sequence",
|
||||||
|
documentCategoryCode: null,
|
||||||
|
...mapSequenceRecord(sequenceRecord),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildConfigFromFallback(normalizedDocumentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDocumentNumber(sequenceConfig, timeZone) {
|
||||||
|
const datePart = compactDateString(timeZone);
|
||||||
|
const serial = String(sequenceConfig.current_number).padStart(sequenceConfig.number_length, "0");
|
||||||
|
return `${sequenceConfig.prefix}${sequenceConfig.separator}${datePart}${sequenceConfig.separator}${serial}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueDocumentNumber(
|
||||||
|
tx,
|
||||||
|
{ documentType, documentCategoryCode = null, actorId = null, timeZone = "Asia/Shanghai" } = {},
|
||||||
|
) {
|
||||||
|
const normalizedDocumentType = normalizeDocumentType(documentType);
|
||||||
|
const config = await resolveDocumentSequenceConfig(tx, {
|
||||||
|
documentType: normalizedDocumentType,
|
||||||
|
documentCategoryCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sequenceRecord = await tx.documentSequence.upsert({
|
||||||
|
where: { document_type: normalizedDocumentType },
|
||||||
|
update: {
|
||||||
|
prefix: config.prefix,
|
||||||
|
date_format: config.dateFormat,
|
||||||
|
separator: config.separator,
|
||||||
|
number_length: config.numberLength,
|
||||||
|
reset_period: config.resetPeriod,
|
||||||
|
current_number: { increment: 1 },
|
||||||
|
updated_by: actorId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
document_type: normalizedDocumentType,
|
||||||
|
prefix: config.prefix,
|
||||||
|
date_format: config.dateFormat,
|
||||||
|
separator: config.separator,
|
||||||
|
number_length: config.numberLength,
|
||||||
|
reset_period: config.resetPeriod,
|
||||||
|
current_number: 1,
|
||||||
|
updated_by: actorId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
document_type: true,
|
||||||
|
prefix: true,
|
||||||
|
date_format: true,
|
||||||
|
separator: true,
|
||||||
|
number_length: true,
|
||||||
|
reset_period: true,
|
||||||
|
current_number: true,
|
||||||
|
last_reset_date: true,
|
||||||
|
updated_at: true,
|
||||||
|
updated_by: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappedSequence = mapSequenceRecord(sequenceRecord);
|
||||||
|
return {
|
||||||
|
documentNumber: formatDocumentNumber(
|
||||||
|
{
|
||||||
|
prefix: mappedSequence.prefix,
|
||||||
|
separator: mappedSequence.separator,
|
||||||
|
number_length: mappedSequence.numberLength,
|
||||||
|
current_number: mappedSequence.currentNumber,
|
||||||
|
},
|
||||||
|
timeZone,
|
||||||
|
),
|
||||||
|
sequence: mappedSequence,
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
currentNumber: mappedSequence.currentNumber,
|
||||||
|
updatedAt: mappedSequence.updatedAt,
|
||||||
|
updatedBy: mappedSequence.updatedBy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeDocumentType,
|
||||||
|
resolveDocumentSequenceConfig,
|
||||||
|
issueDocumentNumber,
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const {
|
||||||
|
resolveBudgetScope,
|
||||||
|
resolveBudgetScopeType,
|
||||||
|
selectBudgetPoolCandidate,
|
||||||
|
} = require("../../src/services/budgetPool");
|
||||||
|
|
||||||
|
describe("budgetPool service", () => {
|
||||||
|
it("resolves cost center scope with legal entity", () => {
|
||||||
|
expect(
|
||||||
|
resolveBudgetScope({
|
||||||
|
legalEntityId: "le-1",
|
||||||
|
costCenterId: "cc-1",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
scopeType: "cost_center_scoped",
|
||||||
|
departmentId: null,
|
||||||
|
legalEntityId: "le-1",
|
||||||
|
costCenterId: "cc-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects mixed department and master-data dimensions", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveBudgetScope({
|
||||||
|
departmentId: "dept-1",
|
||||||
|
legalEntityId: "le-1",
|
||||||
|
}),
|
||||||
|
).toThrow(/参数校验失败/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers cost center budget over department and global budgets", () => {
|
||||||
|
const selected = selectBudgetPoolCandidate(
|
||||||
|
[
|
||||||
|
{ id: "global-1", department_id: null, legal_entity_id: null, cost_center_id: null },
|
||||||
|
{ id: "dept-1", department_id: "dept-a", legal_entity_id: null, cost_center_id: null },
|
||||||
|
{ id: "scope-1", department_id: null, legal_entity_id: "le-1", cost_center_id: "cc-1" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
departmentId: "dept-a",
|
||||||
|
legalEntityId: "le-1",
|
||||||
|
costCenterId: "cc-1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected?.id).toBe("scope-1");
|
||||||
|
expect(resolveBudgetScopeType(selected)).toBe("cost_center_scoped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to legacy department budget when no scoped budget exists", () => {
|
||||||
|
const selected = selectBudgetPoolCandidate(
|
||||||
|
[
|
||||||
|
{ id: "global-1", department_id: null, legal_entity_id: null, cost_center_id: null },
|
||||||
|
{ id: "dept-1", department_id: "dept-a", legal_entity_id: null, cost_center_id: null },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
departmentId: "dept-a",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected?.id).toBe("dept-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when same-priority pools are ambiguous", () => {
|
||||||
|
expect(() =>
|
||||||
|
selectBudgetPoolCandidate(
|
||||||
|
[
|
||||||
|
{ id: "scope-1", department_id: null, legal_entity_id: "le-1", cost_center_id: "cc-1" },
|
||||||
|
{ id: "scope-2", department_id: null, legal_entity_id: "le-1", cost_center_id: "cc-1" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
legalEntityId: "le-1",
|
||||||
|
costCenterId: "cc-1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toThrow(/参数校验失败/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const {
|
||||||
|
issueDocumentNumber,
|
||||||
|
resolveDocumentSequenceConfig,
|
||||||
|
} = require("../../src/services/documentSequence");
|
||||||
|
|
||||||
|
function buildPrisma({ sequenceMap = {}, categoryMap = {} } = {}) {
|
||||||
|
return {
|
||||||
|
documentSequence: {
|
||||||
|
findUnique: async ({ where }) => sequenceMap[where.document_type] ?? null,
|
||||||
|
upsert: async ({ where, update, create }) => {
|
||||||
|
const current = sequenceMap[where.document_type];
|
||||||
|
if (current) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
prefix: update.prefix,
|
||||||
|
date_format: update.date_format,
|
||||||
|
separator: update.separator,
|
||||||
|
number_length: update.number_length,
|
||||||
|
reset_period: update.reset_period,
|
||||||
|
current_number: current.current_number + 1,
|
||||||
|
updated_by: update.updated_by,
|
||||||
|
updated_at: new Date("2026-03-07T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
document_type: create.document_type,
|
||||||
|
prefix: create.prefix,
|
||||||
|
date_format: create.date_format,
|
||||||
|
separator: create.separator,
|
||||||
|
number_length: create.number_length,
|
||||||
|
reset_period: create.reset_period,
|
||||||
|
current_number: create.current_number,
|
||||||
|
last_reset_date: null,
|
||||||
|
updated_at: new Date("2026-03-07T00:00:00.000Z"),
|
||||||
|
updated_by: create.updated_by,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentCategory: {
|
||||||
|
findUnique: async ({ where }) => categoryMap[where.code] ?? null,
|
||||||
|
findFirst: async ({ where }) =>
|
||||||
|
Object.values(categoryMap).find(
|
||||||
|
(item) => item.entity_type === where.entity_type && item.enabled && item.archived_at == null,
|
||||||
|
) ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("documentSequence service", () => {
|
||||||
|
it("keeps reimbursement on legacy sequence when sequence row exists", async () => {
|
||||||
|
const prisma = buildPrisma({
|
||||||
|
sequenceMap: {
|
||||||
|
reimbursement: {
|
||||||
|
document_type: "reimbursement",
|
||||||
|
prefix: "BX",
|
||||||
|
date_format: "YYYYMMDD",
|
||||||
|
separator: "-",
|
||||||
|
number_length: 4,
|
||||||
|
reset_period: "none",
|
||||||
|
current_number: 12,
|
||||||
|
last_reset_date: null,
|
||||||
|
updated_at: new Date("2026-03-07T00:00:00.000Z"),
|
||||||
|
updated_by: "tester",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
categoryMap: {
|
||||||
|
reimbursement_general: {
|
||||||
|
code: "reimbursement_general",
|
||||||
|
entity_type: "reimbursement",
|
||||||
|
sequence_prefix: "RB",
|
||||||
|
number_length: 6,
|
||||||
|
reset_period: "yearly",
|
||||||
|
enabled: true,
|
||||||
|
archived_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await resolveDocumentSequenceConfig(prisma, { documentType: "reimbursement" });
|
||||||
|
|
||||||
|
expect(config.source).toBe("document_sequence");
|
||||||
|
expect(config.prefix).toBe("BX");
|
||||||
|
expect(config.resetPeriod).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to default purchase category when sequence row does not exist", async () => {
|
||||||
|
const prisma = buildPrisma({
|
||||||
|
categoryMap: {
|
||||||
|
purchase_general: {
|
||||||
|
code: "purchase_general",
|
||||||
|
entity_type: "purchase_request",
|
||||||
|
sequence_prefix: "CG",
|
||||||
|
number_length: 4,
|
||||||
|
reset_period: "yearly",
|
||||||
|
enabled: true,
|
||||||
|
archived_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await resolveDocumentSequenceConfig(prisma, { documentType: "purchase_request" });
|
||||||
|
|
||||||
|
expect(config.source).toBe("document_category");
|
||||||
|
expect(config.documentCategoryCode).toBe("purchase_general");
|
||||||
|
expect(config.prefix).toBe("CG");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues a document number using category-driven config", async () => {
|
||||||
|
const prisma = buildPrisma({
|
||||||
|
categoryMap: {
|
||||||
|
purchase_general: {
|
||||||
|
code: "purchase_general",
|
||||||
|
entity_type: "purchase_request",
|
||||||
|
sequence_prefix: "CG",
|
||||||
|
number_length: 4,
|
||||||
|
reset_period: "yearly",
|
||||||
|
enabled: true,
|
||||||
|
archived_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const issued = await issueDocumentNumber(prisma, {
|
||||||
|
documentType: "purchase_request",
|
||||||
|
actorId: "tester",
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issued.config.source).toBe("document_category");
|
||||||
|
expect(issued.documentNumber).toMatch(/^CG-\d{8}-0001$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const { resolveEntityPermission } = require("../../src/constants/permissions");
|
||||||
|
|
||||||
|
describe("resolveEntityPermission", () => {
|
||||||
|
it("maps purchase_request approvals to procurement permissions", () => {
|
||||||
|
expect(resolveEntityPermission("purchase_request", "approve")).toBe("procurement.approve");
|
||||||
|
expect(resolveEntityPermission("purchase_request", "submit")).toBe("procurement.submit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports procurement alias", () => {
|
||||||
|
expect(resolveEntityPermission("procurement", "approve")).toBe("procurement.approve");
|
||||||
|
expect(resolveEntityPermission("procurement", "manage")).toBe("procurement.manage");
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户