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"
|
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 放更后"
|
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 策略"
|
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),并明确密钥轮换策略"
|
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 组装"
|
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 实现,避免重复逻辑"
|
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
|
cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LeaveRequestStatus {
|
||||||
|
draft
|
||||||
|
reviewing
|
||||||
|
approved
|
||||||
|
rejected
|
||||||
|
cancelled
|
||||||
|
}
|
||||||
|
|
||||||
enum InvoicePoolStatus {
|
enum InvoicePoolStatus {
|
||||||
pending_ocr
|
pending_ocr
|
||||||
ocr_failed
|
ocr_failed
|
||||||
@@ -126,6 +134,7 @@ model Department {
|
|||||||
|
|
||||||
employees Employee[]
|
employees Employee[]
|
||||||
reimbursement_forms ReimbursementForm[]
|
reimbursement_forms ReimbursementForm[]
|
||||||
|
leave_requests LeaveRequest[]
|
||||||
workflow_instances WorkflowInstance[]
|
workflow_instances WorkflowInstance[]
|
||||||
|
|
||||||
@@index([parent_id])
|
@@index([parent_id])
|
||||||
@@ -177,6 +186,7 @@ model Employee {
|
|||||||
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
|
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
|
||||||
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
||||||
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
|
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
|
||||||
|
leave_requests LeaveRequest[]
|
||||||
|
|
||||||
owned_invoices InvoicePool[] @relation("InvoiceOwner")
|
owned_invoices InvoicePool[] @relation("InvoiceOwner")
|
||||||
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
||||||
@@ -408,6 +418,32 @@ model ReimbursementItem {
|
|||||||
@@map("reimbursement_items")
|
@@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 {
|
model OcrResult {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
file_sha256 String
|
file_sha256 String
|
||||||
@@ -513,6 +549,8 @@ model WorkflowInstance {
|
|||||||
workflow_id String @db.Uuid
|
workflow_id String @db.Uuid
|
||||||
entity_type String
|
entity_type String
|
||||||
entity_id String @db.Uuid
|
entity_id String @db.Uuid
|
||||||
|
reimbursement_id String? @db.Uuid
|
||||||
|
leave_request_id String? @db.Uuid
|
||||||
submitter_id String @db.Uuid
|
submitter_id String @db.Uuid
|
||||||
current_status WorkflowInstanceStatus @default(pending)
|
current_status WorkflowInstanceStatus @default(pending)
|
||||||
current_step_id String
|
current_step_id String
|
||||||
@@ -526,7 +564,8 @@ model WorkflowInstance {
|
|||||||
updated_by String? @db.Uuid
|
updated_by String? @db.Uuid
|
||||||
|
|
||||||
workflow_definition WorkflowDefinition @relation(fields: [workflow_id], references: [id], onDelete: Restrict)
|
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)
|
submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
|
||||||
department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull)
|
department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@ -538,6 +577,8 @@ model WorkflowInstance {
|
|||||||
|
|
||||||
@@index([workflow_id])
|
@@index([workflow_id])
|
||||||
@@index([entity_type, entity_id])
|
@@index([entity_type, entity_id])
|
||||||
|
@@index([reimbursement_id])
|
||||||
|
@@index([leave_request_id])
|
||||||
@@index([current_status])
|
@@index([current_status])
|
||||||
@@index([submitter_id])
|
@@index([submitter_id])
|
||||||
@@index([context_department_id])
|
@@index([context_department_id])
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const meRoutes = require("./routes/me");
|
|||||||
const reimbursementRoutes = require("./routes/reimbursements");
|
const reimbursementRoutes = require("./routes/reimbursements");
|
||||||
const invoicePoolRoutes = require("./routes/invoicePool");
|
const invoicePoolRoutes = require("./routes/invoicePool");
|
||||||
const workflowRoutes = require("./routes/workflow");
|
const workflowRoutes = require("./routes/workflow");
|
||||||
|
const leaveRoutes = require("./routes/leave");
|
||||||
const delegationRoutes = require("./routes/delegations");
|
const delegationRoutes = require("./routes/delegations");
|
||||||
const auditRoutes = require("./routes/audit");
|
const auditRoutes = require("./routes/audit");
|
||||||
const usersRoutes = require("./routes/users");
|
const usersRoutes = require("./routes/users");
|
||||||
@@ -215,6 +216,7 @@ async function buildApp() {
|
|||||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||||
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
||||||
await app.register(workflowRoutes, { prefix: "/api" });
|
await app.register(workflowRoutes, { prefix: "/api" });
|
||||||
|
await app.register(leaveRoutes, { prefix: "/api" });
|
||||||
await app.register(delegationRoutes, { prefix: "/api" });
|
await app.register(delegationRoutes, { prefix: "/api" });
|
||||||
await app.register(auditRoutes, { prefix: "/api" });
|
await app.register(auditRoutes, { prefix: "/api" });
|
||||||
await app.register(usersRoutes, { 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,
|
workflow_id: workflowDefinition.id,
|
||||||
entity_type: "reimbursement",
|
entity_type: "reimbursement",
|
||||||
entity_id: reimbursementId,
|
entity_id: reimbursementId,
|
||||||
|
reimbursement_id: reimbursementId,
|
||||||
submitter_id: request.employee.id,
|
submitter_id: request.employee.id,
|
||||||
current_status: "pending",
|
current_status: "pending",
|
||||||
current_step_id: actionable.step.id,
|
current_step_id: actionable.step.id,
|
||||||
|
|||||||
+182
-130
@@ -203,7 +203,10 @@ function listAllowedActionsForTask(request, task, actionable) {
|
|||||||
actions.push("approve", "reject", "return");
|
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");
|
actions.push("transfer");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +298,7 @@ async function loadWorkflowInstanceForTask(tx, instanceId) {
|
|||||||
where: { id: instanceId },
|
where: { id: instanceId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
entity_type: true,
|
||||||
entity_id: true,
|
entity_id: true,
|
||||||
current_step_id: true,
|
current_step_id: true,
|
||||||
context_data: true,
|
context_data: true,
|
||||||
@@ -501,6 +505,8 @@ async function workflowRoutes(fastify) {
|
|||||||
workflow_instance: {
|
workflow_instance: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
entity_type: true,
|
||||||
|
entity_id: true,
|
||||||
current_status: true,
|
current_status: true,
|
||||||
current_step_id: true,
|
current_step_id: true,
|
||||||
started_at: true,
|
started_at: true,
|
||||||
@@ -812,37 +818,39 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
const reimbursement = await loadReimbursementNotifyContext(
|
try {
|
||||||
tx,
|
const reimbursement = await loadReimbursementNotifyContext(
|
||||||
workflowInstance.entity_id,
|
tx,
|
||||||
);
|
workflowInstance.entity_id,
|
||||||
const recipients = await resolveRecipientsForAssignee(tx, {
|
);
|
||||||
assigneeId: actionable.assigneeId,
|
const recipients = await resolveRecipientsForAssignee(tx, {
|
||||||
assigneeRole: actionable.assigneeRole,
|
assigneeId: actionable.assigneeId,
|
||||||
});
|
assigneeRole: actionable.assigneeRole,
|
||||||
|
});
|
||||||
|
|
||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
type: "workflow.task_assigned",
|
type: "workflow.task_assigned",
|
||||||
title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
|
title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
|
||||||
targetType: "workflow_task",
|
targetType: "workflow_task",
|
||||||
targetId: nextTask.id,
|
targetId: nextTask.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "workflow.task_assigned",
|
type: "workflow.task_assigned",
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
|
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
|
||||||
targetPath: `/workflow/tasks/${nextTask.id}`,
|
targetPath: `/workflow/tasks/${nextTask.id}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId: nextTask.id },
|
{ err: error, taskId: nextTask.id },
|
||||||
"Failed to create task assigned notifications",
|
"Failed to create task assigned notifications",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
@@ -850,7 +858,7 @@ async function workflowRoutes(fastify) {
|
|||||||
outcome: "approved",
|
outcome: "approved",
|
||||||
workflowInstanceId: workflowInstance.id,
|
workflowInstanceId: workflowInstance.id,
|
||||||
nextTaskId: nextTask.id,
|
nextTaskId: nextTask.id,
|
||||||
reimbursementStatus: "reviewing",
|
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null,
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -864,13 +872,23 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.reimbursementForm.update({
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
where: { id: workflowInstance.entity_id },
|
await tx.reimbursementForm.update({
|
||||||
data: {
|
where: { id: workflowInstance.entity_id },
|
||||||
status: "approved",
|
data: {
|
||||||
updated_by: request.employee.id,
|
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({
|
await tx.workflowHistory.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -889,33 +907,35 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
try {
|
||||||
const recipients = Array.from(
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
const recipients = Array.from(
|
||||||
);
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
|
);
|
||||||
|
|
||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
type: "reimbursement.approved",
|
type: "reimbursement.approved",
|
||||||
title: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
title: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
|
||||||
targetType: "reimbursement_form",
|
targetType: "reimbursement_form",
|
||||||
targetId: workflowInstance.entity_id,
|
targetId: workflowInstance.entity_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "reimbursement.approved",
|
type: "reimbursement.approved",
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
subject: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
subject: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
|
||||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||||
"Failed to create reimbursement approved notifications",
|
"Failed to create reimbursement approved notifications",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
@@ -923,7 +943,7 @@ async function workflowRoutes(fastify) {
|
|||||||
outcome: "approved",
|
outcome: "approved",
|
||||||
workflowInstanceId: workflowInstance.id,
|
workflowInstanceId: workflowInstance.id,
|
||||||
nextTaskId: null,
|
nextTaskId: null,
|
||||||
reimbursementStatus: "approved",
|
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -982,13 +1002,23 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.reimbursementForm.update({
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
where: { id: workflowInstance.entity_id },
|
await tx.reimbursementForm.update({
|
||||||
data: {
|
where: { id: workflowInstance.entity_id },
|
||||||
status: "rejected",
|
data: {
|
||||||
updated_by: request.employee.id,
|
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({
|
await tx.workflowHistory.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1007,40 +1037,42 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
try {
|
||||||
const recipients = Array.from(
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
const recipients = Array.from(
|
||||||
);
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
|
);
|
||||||
|
|
||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
type: "reimbursement.rejected",
|
type: "reimbursement.rejected",
|
||||||
title: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
title: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
|
||||||
targetType: "reimbursement_form",
|
targetType: "reimbursement_form",
|
||||||
targetId: workflowInstance.entity_id,
|
targetId: workflowInstance.entity_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "reimbursement.rejected",
|
type: "reimbursement.rejected",
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
subject: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
subject: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
|
||||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||||
"Failed to create reimbursement rejected notifications",
|
"Failed to create reimbursement rejected notifications",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
taskId,
|
taskId,
|
||||||
outcome: "rejected",
|
outcome: "rejected",
|
||||||
workflowInstanceId: workflowInstance.id,
|
workflowInstanceId: workflowInstance.id,
|
||||||
reimbursementStatus: "rejected",
|
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1099,13 +1131,23 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.reimbursementForm.update({
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
where: { id: workflowInstance.entity_id },
|
await tx.reimbursementForm.update({
|
||||||
data: {
|
where: { id: workflowInstance.entity_id },
|
||||||
status: "draft",
|
data: {
|
||||||
updated_by: request.employee.id,
|
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({
|
await tx.workflowHistory.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1124,40 +1166,42 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
try {
|
||||||
const recipients = Array.from(
|
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
|
||||||
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
const recipients = Array.from(
|
||||||
);
|
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
|
||||||
|
);
|
||||||
|
|
||||||
await createNotifications(tx, {
|
await createNotifications(tx, {
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
type: "reimbursement.returned",
|
type: "reimbursement.returned",
|
||||||
title: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
title: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
|
||||||
targetType: "reimbursement_form",
|
targetType: "reimbursement_form",
|
||||||
targetId: workflowInstance.entity_id,
|
targetId: workflowInstance.entity_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
emailTasks.push({
|
emailTasks.push({
|
||||||
type: "reimbursement.returned",
|
type: "reimbursement.returned",
|
||||||
recipientEmployeeIds: recipients,
|
recipientEmployeeIds: recipients,
|
||||||
subject: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
subject: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
|
||||||
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
|
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
|
||||||
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
||||||
"Failed to create reimbursement returned notifications",
|
"Failed to create reimbursement returned notifications",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
taskId,
|
taskId,
|
||||||
outcome: "returned",
|
outcome: "returned",
|
||||||
workflowInstanceId: workflowInstance.id,
|
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 assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
||||||
|
|
||||||
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
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);
|
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
|
||||||
await ensureTransferTargetEligible(tx, toApproverId);
|
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) {
|
async function seedExpenseTypes(prisma) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allTypes = [
|
const allTypes = [
|
||||||
@@ -198,6 +211,38 @@ async function seedWorkflowDefinition(prisma) {
|
|||||||
created_by: null,
|
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() {
|
async function main() {
|
||||||
@@ -220,6 +265,8 @@ async function main() {
|
|||||||
console.log("- document sequence: reimbursement(reset_period=none)");
|
console.log("- document sequence: reimbursement(reset_period=none)");
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("- workflow definition: reimbursement v1 (1-step role=ceo)");
|
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 {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户