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 状态推进
这个提交包含在:
@@ -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 实现,避免重复逻辑"
|
||||
|
||||
|
@@ -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;
|
||||
+12
@@ -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])
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户