feat(server): 工作流扩展 leave 实体骨架(F24)

- workflow_instances 解耦 reimbursement FK:新增 reimbursement_id/leave_request_id + 回填迁移
- 新增 leave_requests 表与 /api/leave-requests 最小链路(create/submit)
- workflow approve/reject/return 支持 leave 状态更新;transfer 非报销任务禁用
- seed-config 增加 leave workflow definition;更新 v3 CSV 状态推进
这个提交包含在:
2026-02-09 21:24:53 +08:00
父节点 3cc3013d5a
当前提交 70f4d729f0
共修改 9 个文件,包含 695 行新增和 132 行删除
@@ -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;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/<ID>' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments/<ID>/archive';可选 curl -sS -H 'authorization: Bearer <TOKEN>' '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;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees?page=1&pageSize=1' | jq;取 employeeId 后 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees/<ID>' | jq;再用普通 employee 登录拿 <EMP_TOKEN> 验证仅可 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 实现,避免重复逻辑"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
25 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
26 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 放更后
27 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 策略
28 F24 工作流扩展到其他实体(P7+) 抽象 entity_type 扩展到请假/采购等(先骨架:definition+instance+task 复用) 新实体可创建 instance 并走同审批动作;不影响报销既有链路 manual: seed 一个 leave 定义并跑一条最小链路 manual TODO DONE TODO DONE TODO DONE server/src/routes/workflow.js | server/src/routes/leave.js | server/prisma/schema.prisma ["F19"] 只做骨架与复用点,不做完整业务表单
29 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),并明确密钥轮换策略
30 F26 部门API(departments) 补齐部门树/列表与管理接口:GET /api/departments(默认隐藏归档,可 query 开启);POST/PATCH;POST /:id/archive(无子部门/无在职员工约束);写 audit_logs 部门树可用且过滤正确;归档约束正确;system.users 才可写;审计可追踪 manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/<ID>' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments/<ID>/archive';可选 curl -sS -H 'authorization: Bearer <TOKEN>' '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 组装
31 F27 员工API对齐(新增别名 /api/employees) 对齐规格书:在保留 /api/users 的同时新增 /api/employees 别名路由;补齐 GET详情/PATCH更新/POST archive;并保证本人可读自己的详情 /api/employees 与 /api/users 行为一致;system.users 可增改归档;本人可 GET 自己;审计完整 manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees?page=1&pageSize=1' | jq;取 employeeId 后 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees/<ID>' | jq;再用普通 employee 登录拿 <EMP_TOKEN> 验证仅可 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 实现,避免重复逻辑
@@ -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;
@@ -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;
+42 -1
查看文件
@@ -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])
+2
查看文件
@@ -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" });
+352
查看文件
@@ -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;
+1
查看文件
@@ -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,
+57 -5
查看文件
@@ -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,6 +818,7 @@ async function workflowRoutes(fastify) {
},
});
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(
tx,
@@ -844,13 +851,14 @@ async function workflowRoutes(fastify) {
"Failed to create task assigned notifications",
);
}
}
result = {
taskId,
outcome: "approved",
workflowInstanceId: workflowInstance.id,
nextTaskId: nextTask.id,
reimbursementStatus: "reviewing",
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null,
};
return;
}
@@ -864,6 +872,7 @@ async function workflowRoutes(fastify) {
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
@@ -871,6 +880,15 @@ async function workflowRoutes(fastify) {
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,6 +907,7 @@ async function workflowRoutes(fastify) {
},
});
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
@@ -917,13 +936,14 @@ async function workflowRoutes(fastify) {
"Failed to create reimbursement approved notifications",
);
}
}
result = {
taskId,
outcome: "approved",
workflowInstanceId: workflowInstance.id,
nextTaskId: null,
reimbursementStatus: "approved",
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
};
});
@@ -982,6 +1002,7 @@ async function workflowRoutes(fastify) {
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
@@ -989,6 +1010,15 @@ async function workflowRoutes(fastify) {
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,6 +1037,7 @@ async function workflowRoutes(fastify) {
},
});
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
@@ -1035,12 +1066,13 @@ async function workflowRoutes(fastify) {
"Failed to create reimbursement rejected notifications",
);
}
}
result = {
taskId,
outcome: "rejected",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: "rejected",
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
};
});
@@ -1099,6 +1131,7 @@ async function workflowRoutes(fastify) {
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
@@ -1106,6 +1139,15 @@ async function workflowRoutes(fastify) {
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,6 +1166,7 @@ async function workflowRoutes(fastify) {
},
});
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
@@ -1152,12 +1195,13 @@ async function workflowRoutes(fastify) {
"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);
+47
查看文件
@@ -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();