From 52558f22c31c2f299039187fda4e6ea00bce346c Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 7 Mar 2026 18:59:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(G2-1,G2-2,G2-3):=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E7=BC=96=E5=8F=B7=E4=B8=8E=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E8=B7=A8=E5=9F=9F=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 25 ++ server/prisma/schema.prisma | 36 +- server/src/constants/permissions.js | 16 + server/src/routes/budgets.js | 265 +++++++++++--- server/src/routes/configRead.js | 101 +++--- server/src/routes/reimbursements.js | 51 +-- server/src/routes/workflow.js | 153 ++++++-- server/src/services/budgetPool.js | 175 +++++++++ server/src/services/documentSequence.js | 341 ++++++++++++++++++ server/tests/unit/budgetPool.service.test.js | 77 ++++ .../unit/documentSequence.service.test.js | 133 +++++++ server/tests/unit/permissions.entity.test.js | 13 + 12 files changed, 1211 insertions(+), 175 deletions(-) create mode 100644 server/prisma/migrations/20260307103516_g2_cross_domain_bridge/migration.sql create mode 100644 server/src/services/budgetPool.js create mode 100644 server/src/services/documentSequence.js create mode 100644 server/tests/unit/budgetPool.service.test.js create mode 100644 server/tests/unit/documentSequence.service.test.js create mode 100644 server/tests/unit/permissions.entity.test.js diff --git a/server/prisma/migrations/20260307103516_g2_cross_domain_bridge/migration.sql b/server/prisma/migrations/20260307103516_g2_cross_domain_bridge/migration.sql new file mode 100644 index 00000000..c34195e6 --- /dev/null +++ b/server/prisma/migrations/20260307103516_g2_cross_domain_bridge/migration.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 29670ad0..6526b412 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -410,6 +410,7 @@ model LegalEntity { employees Employee[] holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity") suppliers Supplier[] + budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity") @@index([enabled, archived_at], map: "idx_legal_entities_status") @@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) children CostCenter[] @relation("CostCenterToParent") 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") @@index([legal_entity_id, enabled, archived_at], map: "idx_cost_centers_legal_entity_status") @@ -824,6 +826,8 @@ model BudgetPool { code String @unique name String department_id String? @db.Uuid + legal_entity_id String? @db.Uuid + cost_center_id String? @db.Uuid status String @default("active") @db.VarChar(16) period_start DateTime @db.Date period_end DateTime @db.Date @@ -838,9 +842,12 @@ model BudgetPool { updated_by String? @db.Uuid 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[] @@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") @@map("budget_pools") } @@ -849,6 +856,8 @@ model BudgetLedgerEntry { id String @id @default(uuid()) @db.Uuid budget_pool_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) amount Decimal @db.Decimal(14, 2) note String? @@ -860,6 +869,7 @@ model BudgetLedgerEntry { @@index([budget_pool_id, created_at], map: "idx_budget_ledger_pool_created_at") @@index([reimbursement_id]) + @@index([entity_type, entity_id], map: "idx_budget_ledger_entity") @@map("budget_ledger_entries") } @@ -969,18 +979,19 @@ model WorkflowDefinition { } model WorkflowInstance { - id String @id @default(uuid()) @db.Uuid - workflow_id String @db.Uuid - entity_type String - entity_id String @db.Uuid - reimbursement_id String? @db.Uuid - leave_request_id String? @db.Uuid - submitter_id String @db.Uuid - current_status WorkflowInstanceStatus @default(pending) - current_step_id String - context_data Json - started_at DateTime @default(now()) - completed_at DateTime? + id String @id @default(uuid()) @db.Uuid + workflow_id String @db.Uuid + entity_type String + entity_id String @db.Uuid + reimbursement_id String? @db.Uuid + leave_request_id String? @db.Uuid + purchase_request_id String? @db.Uuid + submitter_id String @db.Uuid + current_status WorkflowInstanceStatus @default(pending) + current_step_id String + context_data Json + started_at DateTime @default(now()) + completed_at DateTime? created_at DateTime @default(now()) created_by String? @db.Uuid @@ -1003,6 +1014,7 @@ model WorkflowInstance { @@index([entity_type, entity_id]) @@index([reimbursement_id]) @@index([leave_request_id]) + @@index([purchase_request_id]) @@index([current_status]) @@index([submitter_id]) @@index([context_department_id]) diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 1691c526..3765a4ac 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -55,6 +55,22 @@ const ENTITY_PERMISSION_MAP = { approve: "leave.approve", 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 = { diff --git a/server/src/routes/budgets.js b/server/src/routes/budgets.js index 5c8af644..505cba63 100644 --- a/server/src/routes/budgets.js +++ b/server/src/routes/budgets.js @@ -3,16 +3,26 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); +const { + resolveBudgetScope, + resolveBudgetScopeType, +} = require("../services/budgetPool"); -const BUDGET_CREATE_SCHEMA = z.object({ - code: z.string().trim().min(2).max(32), - name: z.string().trim().min(1).max(120), - departmentId: z.string().uuid().nullable().optional(), - periodStart: 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})?$/), - warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(), -}); +const UUID_SCHEMA = z.string().uuid(); + +const BUDGET_CREATE_SCHEMA = z + .object({ + code: z.string().trim().min(2).max(32), + name: z.string().trim().min(1).max(120), + 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}$/), + periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/), + warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); const BUDGET_PATCH_SCHEMA = z .object({ @@ -25,11 +35,46 @@ const BUDGET_PATCH_SCHEMA = z const BUDGET_LIST_QUERY_SCHEMA = z.object({ 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(), 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) { return new Date(`${value}T00:00:00.000Z`); } @@ -46,12 +91,37 @@ function mapBudget(item) { const allocated = Number(item.allocated_amount ?? 0); const used = Number(item.used_amount ?? 0); const remaining = allocated - used; + const scopeType = resolveBudgetScopeType(item); return { id: item.id, code: item.code, name: item.name, + scopeType, 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, periodStart: item.period_start?.toISOString?.()?.slice(0, 10) ?? null, periodEnd: item.period_end?.toISOString?.()?.slice(0, 10) ?? null, @@ -66,19 +136,105 @@ function mapBudget(item) { } function parseBudgetId(value) { - const parsed = z.string().uuid().safeParse(value); + const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { id: "invalid_uuid" }, - }); + throw buildValidationError({ id: "invalid_uuid" }); } 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) { fastify.get( "/budgets", @@ -88,12 +244,7 @@ async function budgetsRoutes(fastify) { async (request, reply) => { const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildValidationError(parsed.error.flatten()); } const query = parsed.data; @@ -104,6 +255,8 @@ async function budgetsRoutes(fastify) { const where = { ...(query.status ? { status: query.status } : {}), ...(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([ @@ -112,6 +265,7 @@ async function budgetsRoutes(fastify) { orderBy: [{ period_start: "desc" }, { created_at: "desc" }], skip, take: pageSize, + include: BUDGET_RELATION_SELECT, }), fastify.prisma.budgetPool.count({ where }), ]); @@ -133,31 +287,36 @@ async function budgetsRoutes(fastify) { async (request, reply) => { const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildValidationError(parsed.error.flatten()); } const body = parsed.data; const start = toDate(body.periodStart); const end = toDate(body.periodEnd); if (start > end) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { periodEnd: "must_be_after_periodStart" }, - }); + throw buildValidationError({ 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({ data: { - code: body.code.toUpperCase(), - name: body.name, - department_id: body.departmentId ?? null, + code: body.code.trim().toUpperCase(), + name: body.name.trim(), + department_id: scope.departmentId, + legal_entity_id: scope.legalEntityId, + cost_center_id: scope.costCenterId, status: "active", period_start: start, period_end: end, @@ -167,6 +326,7 @@ async function budgetsRoutes(fastify) { created_by: request.employee.id, updated_by: request.employee.id, }, + include: BUDGET_RELATION_SELECT, }); await fastify.prisma.auditLog.create({ @@ -177,6 +337,10 @@ async function budgetsRoutes(fastify) { targetId: created.id, extra: { code: created.code, + scopeType: scope.scopeType, + departmentId: scope.departmentId, + legalEntityId: scope.legalEntityId, + costCenterId: scope.costCenterId, allocatedAmount: created.allocated_amount, }, }), @@ -195,36 +359,30 @@ async function budgetsRoutes(fastify) { const budgetId = parseBudgetId(request.params?.id); const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildValidationError(parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { body: "empty_patch" }, - }); + throw buildValidationError({ body: "empty_patch" }); } + await fetchBudgetPoolOrThrow(fastify.prisma, budgetId); + const patch = parsed.data; const updated = await fastify.prisma.budgetPool.update({ where: { id: budgetId }, data: { - ...(patch.name != null ? { name: patch.name } : {}), + ...(patch.name != null ? { name: patch.name.trim() } : {}), ...(patch.allocatedAmount != null ? { allocated_amount: patch.allocatedAmount } : {}), ...(patch.warningThresholdPercent != null ? { warning_threshold_percent: patch.warningThresholdPercent } : {}), ...(patch.status ? { status: patch.status } : {}), ...(patch.status === "archived" ? { archived_at: new Date() } : {}), + ...(patch.status === "active" ? { archived_at: null } : {}), updated_by: request.employee.id, }, + include: BUDGET_RELATION_SELECT, }); await fastify.prisma.auditLog.create({ @@ -250,6 +408,7 @@ async function budgetsRoutes(fastify) { }, async (request, reply) => { const budgetId = parseBudgetId(request.params?.id); + const current = await fetchBudgetPoolOrThrow(fastify.prisma, budgetId); const updated = await fastify.prisma.budgetPool.update({ where: { id: budgetId }, @@ -258,6 +417,7 @@ async function budgetsRoutes(fastify) { archived_at: new Date(), updated_by: request.employee.id, }, + include: BUDGET_RELATION_SELECT, }); await fastify.prisma.auditLog.create({ @@ -267,7 +427,8 @@ async function budgetsRoutes(fastify) { targetType: "budget_pool", targetId: budgetId, extra: { - code: updated.code, + code: current.code, + scopeType: resolveBudgetScopeType(current), }, }), }); diff --git a/server/src/routes/configRead.js b/server/src/routes/configRead.js index adcda409..9be5cd83 100644 --- a/server/src/routes/configRead.js +++ b/server/src/routes/configRead.js @@ -2,11 +2,19 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); +const { + normalizeDocumentType, + resolveDocumentSequenceConfig, +} = require("../services/documentSequence"); const ENABLED_QUERY_SCHEMA = z.object({ 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) { const parsed = ENABLED_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { @@ -31,6 +39,20 @@ function parseEnabledQuery(query) { 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) { return { id: item.id, @@ -47,39 +69,30 @@ function mapApprovalRule(item) { }; } -function mapDocumentSequence(item, exists) { - 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 }; - } - +function mapDocumentSequence(config) { return { - exists, - documentType: item.document_type, - prefix: item.prefix, - dateFormat: item.date_format, - separator: item.separator, - numberLength: item.number_length, - resetPeriod: item.reset_period, - currentNumber: item.current_number, - lastResetDate: item.last_reset_date?.toISOString?.()?.slice(0, 10) ?? null, - updatedAt: item.updated_at?.toISOString?.() ?? null, - updatedBy: item.updated_by ?? null, + exists: Boolean(config.exists), + source: config.source, + documentType: config.documentType, + documentCategoryCode: config.documentCategoryCode ?? null, + prefix: config.prefix, + dateFormat: config.dateFormat, + separator: config.separator, + numberLength: config.numberLength, + resetPeriod: config.resetPeriod, + currentNumber: config.currentNumber, + 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) { fastify.get( "/approval-rules", @@ -122,26 +135,22 @@ async function configReadRoutes(fastify) { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { - const sequence = await fastify.prisma.documentSequence.findUnique({ - where: { document_type: "reimbursement" }, - 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 config = await loadDocumentSequenceConfig(fastify, "reimbursement"); + return sendData(reply, request, mapDocumentSequence(config)); + }, + ); - 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; - diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 15b97448..0fdc4329 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -2,7 +2,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); -const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date"); +const { currentDateString, parseDateOnlyString } = require("../utils/date"); const { findActiveDelegationForDelegate } = require("../utils/delegation"); const { createNotifications, @@ -12,6 +12,8 @@ const { sendNotificationEmailSafe } = require("../services/email"); const { enqueueIntegrationEvent } = require("../services/integrations/outbox"); const { buildErpPayload } = require("../services/integrations/erp"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); +const { issueDocumentNumber } = require("../services/documentSequence"); const { buildWorkflowStepsFromApprovalRule, 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) { const bodySchema = z.object({ comment: z.string().trim().min(1).max(500).optional(), @@ -350,14 +346,9 @@ async function loadApprovalRuleForAmount(tx, totalAmount) { } async function resolveApplicableBudgetPool(tx, reimbursement) { - return tx.budgetPool.findFirst({ - where: { - status: "active", - 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" }], + return resolveApplicableBudgetPoolByContext(tx, { + departmentId: reimbursement.department_id, + effectiveDate: reimbursement.reimbursement_date, }); } @@ -1204,7 +1195,7 @@ async function reimbursementRoutes(fastify) { await fastify.prisma.$transaction(async (tx) => { 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" WHERE id = ${reimbursementId}::uuid FOR UPDATE @@ -1371,31 +1362,13 @@ async function reimbursementRoutes(fastify) { let formNumber = reimbursement.form_number; if (!formNumber) { - const sequence = await tx.documentSequence.upsert({ - where: { document_type: "reimbursement" }, - update: { - current_number: { increment: 1 }, - 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, - }, + const issued = await issueDocumentNumber(tx, { + documentType: "reimbursement", + actorId: request.employee.id, + timeZone: fastify.config.appTimezone, }); - formNumber = formatFormNumber(sequence, fastify.config.appTimezone); + formNumber = issued.documentNumber; } await tx.reimbursementForm.update({ diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 414d2a59..9eb66dbd 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -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) { for (const skipped of skippedSteps) { await tx.workflowHistory.create({ @@ -892,6 +963,12 @@ async function workflowRoutes(fastify) { tx, workflowInstance.entity_id, ); + const entityMeta = getWorkflowEntityMeta( + workflowInstance.entity_type, + reimbursement, + workflowInstance.entity_id, + ); + const pendingCopy = buildPendingTaskCopy(entityMeta); const recipients = await resolveRecipientsForAssignee(tx, { assigneeId: actionable.assigneeId, assigneeRole: actionable.assigneeRole, @@ -900,8 +977,8 @@ async function workflowRoutes(fastify) { await createNotifications(tx, { recipientEmployeeIds: recipients, type: "workflow.task_assigned", - title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`, + title: pendingCopy.title, + body: pendingCopy.body, targetType: "workflow_task", targetId: nextTask.id, }); @@ -909,8 +986,8 @@ async function workflowRoutes(fastify) { emailTasks.push({ type: "workflow.task_assigned", recipientEmployeeIds: recipients, - subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`, + subject: pendingCopy.title, + body: pendingCopy.body, targetPath: `/workflow/tasks/${nextTask.id}`, }); } catch (error) { @@ -1013,6 +1090,12 @@ async function workflowRoutes(fastify) { if (workflowInstance.entity_type === "reimbursement") { try { 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( new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), ); @@ -1020,18 +1103,18 @@ async function workflowRoutes(fastify) { await createNotifications(tx, { recipientEmployeeIds: recipients, type: "reimbursement.approved", - title: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`, - targetType: "reimbursement_form", + title: approvedCopy.title, + body: approvedCopy.body, + targetType: entityMeta.targetType, targetId: workflowInstance.entity_id, }); emailTasks.push({ type: "reimbursement.approved", recipientEmployeeIds: recipients, - subject: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`, - targetPath: `/reimbursements/${workflowInstance.entity_id}`, + subject: approvedCopy.title, + body: approvedCopy.body, + targetPath: entityMeta.detailPath, }); } catch (error) { fastify.log.error( @@ -1179,6 +1262,12 @@ async function workflowRoutes(fastify) { if (workflowInstance.entity_type === "reimbursement") { try { 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( new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), ); @@ -1186,18 +1275,18 @@ async function workflowRoutes(fastify) { await createNotifications(tx, { recipientEmployeeIds: recipients, type: "reimbursement.rejected", - title: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`, - targetType: "reimbursement_form", + title: rejectedCopy.title, + body: rejectedCopy.body, + targetType: entityMeta.targetType, targetId: workflowInstance.entity_id, }); emailTasks.push({ type: "reimbursement.rejected", recipientEmployeeIds: recipients, - subject: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`, - targetPath: `/reimbursements/${workflowInstance.entity_id}`, + subject: rejectedCopy.title, + body: rejectedCopy.body, + targetPath: entityMeta.detailPath, }); } catch (error) { fastify.log.error( @@ -1344,6 +1433,12 @@ async function workflowRoutes(fastify) { if (workflowInstance.entity_type === "reimbursement") { try { 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( new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), ); @@ -1351,18 +1446,18 @@ async function workflowRoutes(fastify) { await createNotifications(tx, { recipientEmployeeIds: recipients, type: "reimbursement.returned", - title: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`, - targetType: "reimbursement_form", + title: returnedCopy.title, + body: returnedCopy.body, + targetType: entityMeta.targetType, targetId: workflowInstance.entity_id, }); emailTasks.push({ type: "reimbursement.returned", recipientEmployeeIds: recipients, - subject: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`, - targetPath: `/reimbursements/${workflowInstance.entity_id}`, + subject: returnedCopy.title, + body: returnedCopy.body, + targetPath: entityMeta.detailPath, }); } catch (error) { fastify.log.error( @@ -1475,11 +1570,17 @@ async function workflowRoutes(fastify) { try { 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, { recipientEmployeeIds: [toApproverId], type: "workflow.task_assigned", - title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 转审待处理`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已转审给你,请及时处理。`, + title: pendingCopy.title, + body: pendingCopy.body, targetType: "workflow_task", targetId: taskId, }); @@ -1487,8 +1588,8 @@ async function workflowRoutes(fastify) { emailTasks.push({ type: "workflow.task_assigned", recipientEmployeeIds: [toApproverId], - subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 转审待处理`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已转审给你,请及时处理。`, + subject: pendingCopy.title, + body: pendingCopy.body, targetPath: `/workflow/tasks/${taskId}`, }); } catch (error) { diff --git a/server/src/services/budgetPool.js b/server/src/services/budgetPool.js new file mode 100644 index 00000000..5590c23d --- /dev/null +++ b/server/src/services/budgetPool.js @@ -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, +}; diff --git a/server/src/services/documentSequence.js b/server/src/services/documentSequence.js new file mode 100644 index 00000000..e15ee7dc --- /dev/null +++ b/server/src/services/documentSequence.js @@ -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, +}; diff --git a/server/tests/unit/budgetPool.service.test.js b/server/tests/unit/budgetPool.service.test.js new file mode 100644 index 00000000..4456ee16 --- /dev/null +++ b/server/tests/unit/budgetPool.service.test.js @@ -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(/参数校验失败/); + }); +}); diff --git a/server/tests/unit/documentSequence.service.test.js b/server/tests/unit/documentSequence.service.test.js new file mode 100644 index 00000000..a3c6f47f --- /dev/null +++ b/server/tests/unit/documentSequence.service.test.js @@ -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$/); + }); +}); diff --git a/server/tests/unit/permissions.entity.test.js b/server/tests/unit/permissions.entity.test.js new file mode 100644 index 00000000..1e076985 --- /dev/null +++ b/server/tests/unit/permissions.entity.test.js @@ -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"); + }); +});