diff --git a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv index bd4185c1..ec63f955 100644 --- a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv +++ b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv @@ -25,7 +25,7 @@ F20,Worker进程拆分(可选),"提供 API 进程与 Worker 进程拆分的 F21,对象存储(MinIO/S3兼容),"存储从本地目录演进到 S3 兼容(先 MinIO),保持 signed-url 契约不变或提供兼容层","上传/预览/签名URL 在 MinIO 下可用;回归与 E2E 通过;迁移路径清晰可回滚","manual: trialrun 环境启用 MinIO 并完成一次上传预览",manual,DONE,DONE,DONE,"server/src/services/storage/* | deploy/trialrun/* | server/README.md","[""F20""]","优先用 @aws-sdk/client-s3;单机优先 MinIO" F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,DONE,DONE,DONE,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后" F23,SSO/OIDC(可选,P7+),"支持 OIDC 登录(企业 SSO),保留现有密码登录作为 fallback","可通过 OIDC 登录并绑定员工;权限模型不变;可配置开关","manual: 配置 OIDC provider 后走一次登录回调",manual,DONE,DONE,DONE,"server/src/routes/auth.js | server/src/services/oidc/* | server/README.md","[""F1""]","优先复用 openid-client/passport 策略" -F24,工作流扩展到其他实体(P7+),"抽象 entity_type 扩展到请假/采购等(先骨架:definition+instance+task 复用)","新实体可创建 instance 并走同审批动作;不影响报销既有链路","manual: seed 一个 leave 定义并跑一条最小链路",manual,TODO,TODO,TODO,"server/src/routes/workflow.js | server/src/routes/leave.js | server/prisma/schema.prisma","[""F19""]","只做骨架与复用点,不做完整业务表单" +F24,工作流扩展到其他实体(P7+),"抽象 entity_type 扩展到请假/采购等(先骨架:definition+instance+task 复用)","新实体可创建 instance 并走同审批动作;不影响报销既有链路","manual: seed 一个 leave 定义并跑一条最小链路",manual,DONE,DONE,DONE,"server/src/routes/workflow.js | server/src/routes/leave.js | server/prisma/schema.prisma","[""F19""]","只做骨架与复用点,不做完整业务表单" F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略" F26,部门API(departments),"补齐部门树/列表与管理接口:GET /api/departments(默认隐藏归档,可 query 开启);POST/PATCH;POST /:id/archive(无子部门/无在职员工约束);写 audit_logs","部门树可用且过滤正确;归档约束正确;system.users 才可写;审计可追踪","manual: 启动 server;以 admin@hyinda.com/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer ' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer ' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/departments//archive';可选 curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/audit-logs?action=DEPARTMENT_*' | jq",manual,DONE,DONE,DONE,"server/src/routes/departments.js | server/src/app.js | server/README.md","[""F1""]","单机试运行优先;树构造按 parent_id 组装" F27,员工API对齐(新增别名 /api/employees),"对齐规格书:在保留 /api/users 的同时新增 /api/employees 别名路由;补齐 GET详情/PATCH更新/POST archive;并保证本人可读自己的详情","/api/employees 与 /api/users 行为一致;system.users 可增改归档;本人可 GET 自己;审计完整","manual: 启动 server;以 admin@hyinda.com/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/employees?page=1&pageSize=1' | jq;取 employeeId 后 curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/employees/' | jq;再用普通 employee 登录拿 验证仅可 GET 自己且 GET 他人返回 403 PERM_001",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/routes/employees.js | server/src/app.js | server/README.md","[""F26""]","优先复用既有 usersRoutes 实现,避免重复逻辑" diff --git a/server/prisma/migrations/20260209131639_workflow_instance_multi_entity_leave/migration.sql b/server/prisma/migrations/20260209131639_workflow_instance_multi_entity_leave/migration.sql new file mode 100644 index 00000000..5ee31a0f --- /dev/null +++ b/server/prisma/migrations/20260209131639_workflow_instance_multi_entity_leave/migration.sql @@ -0,0 +1,56 @@ +-- CreateEnum +CREATE TYPE "LeaveRequestStatus" AS ENUM ('draft', 'reviewing', 'approved', 'rejected', 'cancelled'); + +-- DropForeignKey +ALTER TABLE "workflow_instances" DROP CONSTRAINT "workflow_instances_entity_id_fkey"; + +-- AlterTable +ALTER TABLE "workflow_instances" ADD COLUMN "leave_request_id" UUID, +ADD COLUMN "reimbursement_id" UUID; + +-- CreateTable +CREATE TABLE "leave_requests" ( + "id" UUID NOT NULL, + "owner_id" UUID NOT NULL, + "department_id" UUID NOT NULL, + "start_date" DATE NOT NULL, + "end_date" DATE NOT NULL, + "reason" TEXT, + "status" "LeaveRequestStatus" NOT NULL DEFAULT 'draft', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL, + "updated_by" UUID, + + CONSTRAINT "leave_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "leave_requests_owner_id_idx" ON "leave_requests"("owner_id"); + +-- CreateIndex +CREATE INDEX "leave_requests_department_id_idx" ON "leave_requests"("department_id"); + +-- CreateIndex +CREATE INDEX "leave_requests_status_idx" ON "leave_requests"("status"); + +-- CreateIndex +CREATE INDEX "idx_leave_owner_status_updated_at" ON "leave_requests"("owner_id", "status", "updated_at"); + +-- CreateIndex +CREATE INDEX "workflow_instances_reimbursement_id_idx" ON "workflow_instances"("reimbursement_id"); + +-- CreateIndex +CREATE INDEX "workflow_instances_leave_request_id_idx" ON "workflow_instances"("leave_request_id"); + +-- AddForeignKey +ALTER TABLE "leave_requests" ADD CONSTRAINT "leave_requests_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "leave_requests" ADD CONSTRAINT "leave_requests_department_id_fkey" FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workflow_instances" ADD CONSTRAINT "workflow_instances_reimbursement_id_fkey" FOREIGN KEY ("reimbursement_id") REFERENCES "reimbursement_forms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workflow_instances" ADD CONSTRAINT "workflow_instances_leave_request_id_fkey" FOREIGN KEY ("leave_request_id") REFERENCES "leave_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20260209131830_workflow_instance_backfill_entity_links/migration.sql b/server/prisma/migrations/20260209131830_workflow_instance_backfill_entity_links/migration.sql new file mode 100644 index 00000000..42aac89d --- /dev/null +++ b/server/prisma/migrations/20260209131830_workflow_instance_backfill_entity_links/migration.sql @@ -0,0 +1,12 @@ +-- Backfill workflow_instances.*_id columns for existing rows. +-- Reimbursement instances historically used entity_id as FK target. +UPDATE "workflow_instances" +SET reimbursement_id = entity_id +WHERE entity_type = 'reimbursement' + AND reimbursement_id IS NULL; + +UPDATE "workflow_instances" +SET leave_request_id = entity_id +WHERE entity_type = 'leave' + AND leave_request_id IS NULL; + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 03211164..b9ac7d4f 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -36,6 +36,14 @@ enum ReimbursementStatus { cancelled } +enum LeaveRequestStatus { + draft + reviewing + approved + rejected + cancelled +} + enum InvoicePoolStatus { pending_ocr ocr_failed @@ -126,6 +134,7 @@ model Department { employees Employee[] reimbursement_forms ReimbursementForm[] + leave_requests LeaveRequest[] workflow_instances WorkflowInstance[] @@index([parent_id]) @@ -177,6 +186,7 @@ model Employee { submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter") owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner") paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy") + leave_requests LeaveRequest[] owned_invoices InvoicePool[] @relation("InvoiceOwner") uploaded_invoices InvoicePool[] @relation("InvoiceUploader") @@ -408,6 +418,32 @@ model ReimbursementItem { @@map("reimbursement_items") } +model LeaveRequest { + id String @id @default(uuid()) @db.Uuid + owner_id String @db.Uuid + department_id String @db.Uuid + start_date DateTime @db.Date + end_date DateTime @db.Date + reason String? + status LeaveRequestStatus @default(draft) + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @updatedAt + updated_by String? @db.Uuid + + owner Employee @relation(fields: [owner_id], references: [id], onDelete: Restrict) + department Department @relation(fields: [department_id], references: [id], onDelete: Restrict) + + workflow_instances WorkflowInstance[] + + @@index([owner_id]) + @@index([department_id]) + @@index([status]) + @@index([owner_id, status, updated_at], map: "idx_leave_owner_status_updated_at") + @@map("leave_requests") +} + model OcrResult { id String @id @default(uuid()) @db.Uuid file_sha256 String @@ -513,6 +549,8 @@ model WorkflowInstance { 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 @@ -526,7 +564,8 @@ model WorkflowInstance { updated_by String? @db.Uuid workflow_definition WorkflowDefinition @relation(fields: [workflow_id], references: [id], onDelete: Restrict) - reimbursement_form ReimbursementForm @relation(fields: [entity_id], references: [id], onDelete: Cascade) + reimbursement_form ReimbursementForm? @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade) + leave_request LeaveRequest? @relation(fields: [leave_request_id], references: [id], onDelete: Cascade) submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict) department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull) @@ -538,6 +577,8 @@ model WorkflowInstance { @@index([workflow_id]) @@index([entity_type, entity_id]) + @@index([reimbursement_id]) + @@index([leave_request_id]) @@index([current_status]) @@index([submitter_id]) @@index([context_department_id]) diff --git a/server/src/app.js b/server/src/app.js index aeb971a6..f4fcf32a 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -19,6 +19,7 @@ const meRoutes = require("./routes/me"); const reimbursementRoutes = require("./routes/reimbursements"); const invoicePoolRoutes = require("./routes/invoicePool"); const workflowRoutes = require("./routes/workflow"); +const leaveRoutes = require("./routes/leave"); const delegationRoutes = require("./routes/delegations"); const auditRoutes = require("./routes/audit"); const usersRoutes = require("./routes/users"); @@ -215,6 +216,7 @@ async function buildApp() { await app.register(reimbursementRoutes, { prefix: "/api" }); await app.register(invoicePoolRoutes, { prefix: "/api" }); await app.register(workflowRoutes, { prefix: "/api" }); + await app.register(leaveRoutes, { prefix: "/api" }); await app.register(delegationRoutes, { prefix: "/api" }); await app.register(auditRoutes, { prefix: "/api" }); await app.register(usersRoutes, { prefix: "/api" }); diff --git a/server/src/routes/leave.js b/server/src/routes/leave.js new file mode 100644 index 00000000..91af4533 --- /dev/null +++ b/server/src/routes/leave.js @@ -0,0 +1,352 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { resolveActionableStep } = require("../utils/workflow"); + +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +const createLeaveSchema = z + .object({ + startDate: DATE_ONLY_SCHEMA, + endDate: DATE_ONLY_SCHEMA, + reason: z.string().trim().max(500).optional(), + }) + .strict(); + +const LEAVE_WORKFLOW_STEPS_V1 = [ + { + id: "leave_approval_manager", + name: "部门审批", + type: "approval", + assignee_rule: { type: "department_manager" }, + }, +]; + +function parseLeaveId(value) { + const parsed = z.string().uuid().safeParse(value); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + return parsed.data; +} + +function parseCreateBody(body) { + const parsed = createLeaveSchema.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`); + const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { date: "invalid" }, + }); + } + if (end.getTime() < start.getTime()) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { endDate: "before_start" }, + }); + } + + return { + startDate: start, + endDate: end, + reason: parsed.data.reason?.trim() ?? null, + }; +} + +async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) { + for (const skipped of skippedSteps ?? []) { + await tx.workflowHistory.create({ + data: { + instance_id: workflowInstanceId, + actor_id: actorId, + action: "skip", + payload: { + step_id: skipped.step.id, + reason: skipped.reason, + owner_id: ownerId, + depth_checked: skipped.depthChecked, + }, + }, + }); + } +} + +async function leaveRoutes(fastify) { + fastify.post( + "/leave-requests", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const body = parseCreateBody(request.body); + + const leave = await fastify.prisma.leaveRequest.create({ + data: { + owner_id: request.employee.id, + department_id: request.employee.department_id, + start_date: body.startDate, + end_date: body.endDate, + reason: body.reason, + status: "draft", + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + start_date: true, + end_date: true, + }, + }); + + return sendData(reply, request, { + id: leave.id, + status: leave.status, + startDate: leave.start_date.toISOString().slice(0, 10), + endDate: leave.end_date.toISOString().slice(0, 10), + }); + }, + ); + + fastify.get( + "/leave-requests/:id", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const leaveId = parseLeaveId(request.params?.id); + + const leave = await fastify.prisma.leaveRequest.findUnique({ + where: { id: leaveId }, + select: { + id: true, + owner_id: true, + department_id: true, + start_date: true, + end_date: true, + reason: true, + status: true, + created_at: true, + updated_at: true, + }, + }); + + if (!leave) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); + if (!canViewAll && leave.owner_id !== request.employee.id) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + return sendData(reply, request, { + id: leave.id, + ownerId: leave.owner_id, + departmentId: leave.department_id, + status: leave.status, + startDate: leave.start_date.toISOString().slice(0, 10), + endDate: leave.end_date.toISOString().slice(0, 10), + reason: leave.reason ?? null, + createdAt: leave.created_at.toISOString(), + updatedAt: leave.updated_at.toISOString(), + }); + }, + ); + + fastify.post( + "/leave-requests/:id/submit", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const leaveId = parseLeaveId(request.params?.id); + let submitResult = null; + + await fastify.prisma.$transaction(async (tx) => { + const leaveRows = await tx.$queryRaw` + SELECT id, owner_id, department_id, status + FROM "leave_requests" + WHERE id = ${leaveId}::uuid + FOR UPDATE + `; + if (!Array.isArray(leaveRows) || leaveRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const leave = leaveRows[0]; + if (leave.owner_id !== request.employee.id) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (!["draft", "rejected"].includes(leave.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_submit_status" }, + }); + } + + const activeInstance = await tx.workflowInstance.findFirst({ + where: { + entity_type: "leave", + entity_id: leaveId, + current_status: "pending", + }, + select: { id: true }, + }); + if (activeInstance) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { submit: "already_in_review" }, + }); + } + + const workflowDefinition = await tx.workflowDefinition.findFirst({ + where: { entity_type: "leave", is_active: true }, + orderBy: { version: "desc" }, + select: { id: true }, + }); + if (!workflowDefinition) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { workflow: "missing_definition" }, + }); + } + + const { actionable, skipped } = await resolveActionableStep({ + tx, + steps: LEAVE_WORKFLOW_STEPS_V1, + startIndex: 0, + ownerId: leave.owner_id, + logger: fastify.log, + }); + if (!actionable) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { workflow: "no_actionable_approver" }, + }); + } + + await tx.leaveRequest.update({ + where: { id: leaveId }, + data: { + status: "reviewing", + updated_by: request.employee.id, + }, + }); + + const workflowInstance = await tx.workflowInstance.create({ + data: { + workflow_id: workflowDefinition.id, + entity_type: "leave", + entity_id: leaveId, + leave_request_id: leaveId, + submitter_id: request.employee.id, + current_status: "pending", + current_step_id: actionable.step.id, + context_data: { + owner_id: leave.owner_id, + department_id: leave.department_id, + workflow_steps_snapshot: LEAVE_WORKFLOW_STEPS_V1, + }, + context_department_id: leave.department_id, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { + id: true, + current_step_id: true, + }, + }); + + await createSkipHistoryRecords( + tx, + workflowInstance.id, + request.employee.id, + leave.owner_id, + skipped, + ); + + const workflowTask = await tx.workflowTask.create({ + data: { + instance_id: workflowInstance.id, + step_id: actionable.step.id, + step_name: actionable.step.name ?? actionable.step.id, + assignee_id: actionable.assigneeId, + assignee_role: actionable.assigneeRole, + }, + select: { id: true }, + }); + + await tx.workflowHistory.create({ + data: { + instance_id: workflowInstance.id, + task_id: workflowTask.id, + actor_id: request.employee.id, + action: "start", + payload: { + step_id: actionable.step.id, + skipped_step_count: skipped.length, + }, + }, + }); + + submitResult = { + leaveRequestId: leaveId, + workflowInstanceId: workflowInstance.id, + currentTaskId: workflowTask.id, + status: "reviewing", + }; + }); + + return sendData(reply, request, submitResult); + }, + ); +} + +module.exports = leaveRoutes; + diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 64c83ae2..d4d1c628 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -1289,6 +1289,7 @@ async function reimbursementRoutes(fastify) { workflow_id: workflowDefinition.id, entity_type: "reimbursement", entity_id: reimbursementId, + reimbursement_id: reimbursementId, submitter_id: request.employee.id, current_status: "pending", current_step_id: actionable.step.id, diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index f4a6c53e..72ba1fae 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -203,7 +203,10 @@ function listAllowedActionsForTask(request, task, actionable) { actions.push("approve", "reject", "return"); } - if (hasPermission(request.employee.permissionCodes, "expense.transfer")) { + if ( + task.workflow_instance?.entity_type === "reimbursement" && + hasPermission(request.employee.permissionCodes, "expense.transfer") + ) { actions.push("transfer"); } @@ -295,6 +298,7 @@ async function loadWorkflowInstanceForTask(tx, instanceId) { where: { id: instanceId }, select: { id: true, + entity_type: true, entity_id: true, current_step_id: true, context_data: true, @@ -501,6 +505,8 @@ async function workflowRoutes(fastify) { workflow_instance: { select: { id: true, + entity_type: true, + entity_id: true, current_status: true, current_step_id: true, started_at: true, @@ -812,37 +818,39 @@ async function workflowRoutes(fastify) { }, }); - try { - const reimbursement = await loadReimbursementNotifyContext( - tx, - workflowInstance.entity_id, - ); - const recipients = await resolveRecipientsForAssignee(tx, { - assigneeId: actionable.assigneeId, - assigneeRole: actionable.assigneeRole, - }); + if (workflowInstance.entity_type === "reimbursement") { + try { + const reimbursement = await loadReimbursementNotifyContext( + tx, + workflowInstance.entity_id, + ); + const recipients = await resolveRecipientsForAssignee(tx, { + assigneeId: actionable.assigneeId, + assigneeRole: actionable.assigneeRole, + }); - await createNotifications(tx, { - recipientEmployeeIds: recipients, - type: "workflow.task_assigned", - title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`, - targetType: "workflow_task", - targetId: nextTask.id, - }); + await createNotifications(tx, { + recipientEmployeeIds: recipients, + type: "workflow.task_assigned", + title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`, + body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`, + targetType: "workflow_task", + targetId: nextTask.id, + }); - emailTasks.push({ - type: "workflow.task_assigned", - recipientEmployeeIds: recipients, - subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`, - body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`, - targetPath: `/workflow/tasks/${nextTask.id}`, - }); - } catch (error) { - fastify.log.error( - { err: error, taskId: nextTask.id }, - "Failed to create task assigned notifications", - ); + emailTasks.push({ + type: "workflow.task_assigned", + recipientEmployeeIds: recipients, + subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`, + body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`, + targetPath: `/workflow/tasks/${nextTask.id}`, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId: nextTask.id }, + "Failed to create task assigned notifications", + ); + } } result = { @@ -850,7 +858,7 @@ async function workflowRoutes(fastify) { outcome: "approved", workflowInstanceId: workflowInstance.id, nextTaskId: nextTask.id, - reimbursementStatus: "reviewing", + reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null, }; return; } @@ -864,13 +872,23 @@ async function workflowRoutes(fastify) { }, }); - await tx.reimbursementForm.update({ - where: { id: workflowInstance.entity_id }, - data: { - status: "approved", - updated_by: request.employee.id, - }, - }); + if (workflowInstance.entity_type === "reimbursement") { + await tx.reimbursementForm.update({ + where: { id: workflowInstance.entity_id }, + data: { + status: "approved", + updated_by: request.employee.id, + }, + }); + } else if (workflowInstance.entity_type === "leave") { + await tx.leaveRequest.update({ + where: { id: workflowInstance.entity_id }, + data: { + status: "approved", + updated_by: request.employee.id, + }, + }); + } await tx.workflowHistory.create({ data: { @@ -889,33 +907,35 @@ async function workflowRoutes(fastify) { }, }); - try { - const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); - const recipients = Array.from( - new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), - ); + if (workflowInstance.entity_type === "reimbursement") { + try { + const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); + const recipients = Array.from( + new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), + ); - 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", - targetId: workflowInstance.entity_id, - }); + 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", + 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}`, - }); - } catch (error) { - fastify.log.error( - { err: error, taskId, reimbursementId: workflowInstance.entity_id }, - "Failed to create reimbursement approved notifications", - ); + 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}`, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId, reimbursementId: workflowInstance.entity_id }, + "Failed to create reimbursement approved notifications", + ); + } } result = { @@ -923,7 +943,7 @@ async function workflowRoutes(fastify) { outcome: "approved", workflowInstanceId: workflowInstance.id, nextTaskId: null, - reimbursementStatus: "approved", + reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null, }; }); @@ -982,13 +1002,23 @@ async function workflowRoutes(fastify) { }, }); - await tx.reimbursementForm.update({ - where: { id: workflowInstance.entity_id }, - data: { - status: "rejected", - updated_by: request.employee.id, - }, - }); + if (workflowInstance.entity_type === "reimbursement") { + await tx.reimbursementForm.update({ + where: { id: workflowInstance.entity_id }, + data: { + status: "rejected", + updated_by: request.employee.id, + }, + }); + } else if (workflowInstance.entity_type === "leave") { + await tx.leaveRequest.update({ + where: { id: workflowInstance.entity_id }, + data: { + status: "rejected", + updated_by: request.employee.id, + }, + }); + } await tx.workflowHistory.create({ data: { @@ -1007,40 +1037,42 @@ async function workflowRoutes(fastify) { }, }); - try { - const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); - const recipients = Array.from( - new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), - ); + if (workflowInstance.entity_type === "reimbursement") { + try { + const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); + const recipients = Array.from( + new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), + ); - 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", - targetId: workflowInstance.entity_id, - }); + 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", + 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}`, - }); - } catch (error) { - fastify.log.error( - { err: error, taskId, reimbursementId: workflowInstance.entity_id }, - "Failed to create reimbursement rejected notifications", - ); + 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}`, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId, reimbursementId: workflowInstance.entity_id }, + "Failed to create reimbursement rejected notifications", + ); + } } result = { taskId, outcome: "rejected", workflowInstanceId: workflowInstance.id, - reimbursementStatus: "rejected", + reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null, }; }); @@ -1099,13 +1131,23 @@ async function workflowRoutes(fastify) { }, }); - await tx.reimbursementForm.update({ - where: { id: workflowInstance.entity_id }, - data: { - status: "draft", - updated_by: request.employee.id, - }, - }); + if (workflowInstance.entity_type === "reimbursement") { + await tx.reimbursementForm.update({ + where: { id: workflowInstance.entity_id }, + data: { + status: "draft", + updated_by: request.employee.id, + }, + }); + } else if (workflowInstance.entity_type === "leave") { + await tx.leaveRequest.update({ + where: { id: workflowInstance.entity_id }, + data: { + status: "draft", + updated_by: request.employee.id, + }, + }); + } await tx.workflowHistory.create({ data: { @@ -1124,40 +1166,42 @@ async function workflowRoutes(fastify) { }, }); - try { - const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); - const recipients = Array.from( - new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), - ); + if (workflowInstance.entity_type === "reimbursement") { + try { + const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); + const recipients = Array.from( + new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), + ); - 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", - targetId: workflowInstance.entity_id, - }); + 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", + 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}`, - }); - } catch (error) { - fastify.log.error( - { err: error, taskId, reimbursementId: workflowInstance.entity_id }, - "Failed to create reimbursement returned notifications", - ); + 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}`, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId, reimbursementId: workflowInstance.entity_id }, + "Failed to create reimbursement returned notifications", + ); + } } result = { taskId, outcome: "returned", workflowInstanceId: workflowInstance.id, - reimbursementStatus: "draft", + reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null, }; }); @@ -1192,6 +1236,14 @@ async function workflowRoutes(fastify) { const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); + if (workflowInstance.entity_type !== "reimbursement") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { transfer: "unsupported_entity_type" }, + }); + } const transferCount = await ensureTransferChainNotExceeded(tx, taskId); await ensureTransferTargetEligible(tx, toApproverId); diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index 7ec31298..3b651a78 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -92,6 +92,19 @@ const DEFAULT_WORKFLOW_DEFINITION = { ], }; +const DEFAULT_LEAVE_WORKFLOW_DEFINITION = { + steps: [ + { + id: "leave_approval_manager", + name: "部门审批", + type: "approval", + assignee_rule: { + type: "department_manager", + }, + }, + ], +}; + async function seedExpenseTypes(prisma) { const now = new Date(); const allTypes = [ @@ -198,6 +211,38 @@ async function seedWorkflowDefinition(prisma) { created_by: null, }, }); + + await prisma.workflowDefinition.updateMany({ + where: { + entity_type: "leave", + }, + data: { + is_active: false, + }, + }); + + await prisma.workflowDefinition.upsert({ + where: { + entity_type_version: { + entity_type: "leave", + version: 1, + }, + }, + update: { + name: "请假审批流程(MVP)", + definition_json: DEFAULT_LEAVE_WORKFLOW_DEFINITION, + is_active: true, + created_by: null, + }, + create: { + name: "请假审批流程(MVP)", + entity_type: "leave", + definition_json: DEFAULT_LEAVE_WORKFLOW_DEFINITION, + version: 1, + is_active: true, + created_by: null, + }, + }); } async function main() { @@ -220,6 +265,8 @@ async function main() { console.log("- document sequence: reimbursement(reset_period=none)"); // eslint-disable-next-line no-console console.log("- workflow definition: reimbursement v1 (1-step role=ceo)"); + // eslint-disable-next-line no-console + console.log("- workflow definition: leave v1 (1-step department_manager)"); } finally { await prisma.$disconnect(); await pool.end();