- workflow: 任务详情/转审候选/列表分页字段(D5) - reimbursements: 列表API + pay凭证字段/幂等增强(D7) - invoice-pool: 列表/详情API + 上传pending_ocr(D9) - ocr/audit: mock provider + 回归脚本 + regression-all开关(D2/D3) - CI: 新增 ci-minimal / ci-regression 工作流(D4)
1096 行
30 KiB
JavaScript
1096 行
30 KiB
JavaScript
const { z } = require("zod");
|
|
const { AppError } = require("../utils/AppError");
|
|
const { sendData } = require("../utils/reply");
|
|
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions");
|
|
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
|
const {
|
|
findActiveDelegationForDelegate,
|
|
listActiveDelegationPrincipals,
|
|
} = require("../utils/delegation");
|
|
|
|
const TASK_ID_SCHEMA = z.string().uuid();
|
|
const REQUIRED_COMMENT_SCHEMA = z.object({
|
|
comment: z.string().trim().min(1).max(500),
|
|
});
|
|
const TRANSFER_BODY_SCHEMA = z.object({
|
|
toApproverId: z.string().uuid(),
|
|
reason: z.string().max(500),
|
|
});
|
|
|
|
const TASK_LIST_QUERY_SCHEMA = z.object({
|
|
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
|
|
page: z.coerce.number().int().min(1).optional(),
|
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
|
});
|
|
|
|
const APPROVER_LIST_QUERY_SCHEMA = z.object({
|
|
keyword: z.string().trim().max(100).optional(),
|
|
page: z.coerce.number().int().min(1).optional(),
|
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
|
});
|
|
|
|
const APPROVER_ROLE_CODES = Object.keys(ROLE_PERMISSIONS).filter((roleCode) =>
|
|
hasPermission(ROLE_PERMISSIONS[roleCode], "expense.approve"),
|
|
);
|
|
|
|
function parseRequiredComment(body) {
|
|
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
return parsed.data.comment;
|
|
}
|
|
|
|
function parseTransferBody(body) {
|
|
const parsed = TRANSFER_BODY_SCHEMA.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
const reason = parsed.data.reason.trim();
|
|
if (reason.length < 10) {
|
|
throw new AppError({
|
|
code: "VALID_002",
|
|
message: "转审原因不足 10 字",
|
|
statusCode: 400,
|
|
details: { reason: "too_short" },
|
|
});
|
|
}
|
|
|
|
return {
|
|
toApproverId: parsed.data.toApproverId,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function parseTaskId(taskId) {
|
|
const parsed = TASK_ID_SCHEMA.safeParse(taskId);
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { taskId: "invalid_uuid" },
|
|
});
|
|
}
|
|
|
|
return parsed.data;
|
|
}
|
|
|
|
function parseTaskListQuery(query) {
|
|
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
return {
|
|
status: parsed.data.status ?? "pending",
|
|
page: parsed.data.page ?? 1,
|
|
pageSize: parsed.data.pageSize ?? 20,
|
|
};
|
|
}
|
|
|
|
function parseApproverListQuery(query) {
|
|
const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
return {
|
|
keyword: parsed.data.keyword ?? "",
|
|
page: parsed.data.page ?? 1,
|
|
pageSize: parsed.data.pageSize ?? 20,
|
|
};
|
|
}
|
|
|
|
function isoOrNull(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
return value.toISOString();
|
|
}
|
|
|
|
function mapTaskSummary(task) {
|
|
const workflowInstance = task.workflow_instance
|
|
? {
|
|
entity_type: task.workflow_instance.entity_type,
|
|
entity_id: task.workflow_instance.entity_id,
|
|
current_status: task.workflow_instance.current_status,
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
id: task.id,
|
|
instance_id: task.instance_id,
|
|
step_id: task.step_id,
|
|
step_name: task.step_name,
|
|
assignee_id: task.assignee_id,
|
|
assignee_role: task.assignee_role,
|
|
status: task.status,
|
|
outcome: task.outcome,
|
|
created_at: isoOrNull(task.created_at),
|
|
completed_at: isoOrNull(task.completed_at),
|
|
workflow_instance: workflowInstance,
|
|
|
|
instanceId: task.instance_id,
|
|
stepId: task.step_id,
|
|
stepName: task.step_name,
|
|
assigneeId: task.assignee_id,
|
|
assigneeRole: task.assignee_role,
|
|
createdAt: isoOrNull(task.created_at),
|
|
completedAt: isoOrNull(task.completed_at),
|
|
workflowInstance: workflowInstance
|
|
? {
|
|
entityType: workflowInstance.entity_type,
|
|
entityId: workflowInstance.entity_id,
|
|
currentStatus: workflowInstance.current_status,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
async function buildTaskVisibilityFilters(tx, request) {
|
|
const roleFilters = request.employee.roleCodes.length
|
|
? [{ assignee_role: { in: request.employee.roleCodes } }]
|
|
: [];
|
|
const delegatedPrincipalIds = await listActiveDelegationPrincipals(tx, {
|
|
delegateEmployeeId: request.employee.id,
|
|
allowedScopes: ["all"],
|
|
});
|
|
|
|
const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters];
|
|
if (delegatedPrincipalIds.length) {
|
|
assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } });
|
|
}
|
|
|
|
return assigneeFilters;
|
|
}
|
|
|
|
function listAllowedActionsForTask(request, task, actionable) {
|
|
if (task.status !== "pending" || !actionable) {
|
|
return [];
|
|
}
|
|
|
|
const actions = [];
|
|
if (hasPermission(request.employee.permissionCodes, "expense.approve")) {
|
|
actions.push("approve", "reject", "return");
|
|
}
|
|
|
|
if (hasPermission(request.employee.permissionCodes, "expense.transfer")) {
|
|
actions.push("transfer");
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
async function lockWorkflowTask(tx, taskId) {
|
|
const taskRows = await tx.$queryRaw`
|
|
SELECT id, instance_id, step_id, step_name, assignee_id, assignee_role, status
|
|
FROM "workflow_tasks"
|
|
WHERE id = ${taskId}::uuid
|
|
FOR UPDATE
|
|
`;
|
|
|
|
if (!Array.isArray(taskRows) || taskRows.length === 0) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
return taskRows[0];
|
|
}
|
|
|
|
function ensurePendingTask(task) {
|
|
if (task.status !== "pending") {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { task: "already_processed" },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function ensureTaskAssignableToCurrentEmployee(tx, task, request) {
|
|
const matchesAssigneeId = Boolean(task.assignee_id && task.assignee_id === request.employee.id);
|
|
const matchesAssigneeRole = Boolean(
|
|
task.assignee_role && request.employee.roleCodes.includes(task.assignee_role),
|
|
);
|
|
const isUnassigned = !task.assignee_id && !task.assignee_role;
|
|
|
|
if (matchesAssigneeId || matchesAssigneeRole || isUnassigned) {
|
|
return {
|
|
delegated: false,
|
|
delegationId: null,
|
|
principalId: null,
|
|
};
|
|
}
|
|
|
|
if (task.assignee_id) {
|
|
const delegation = await findActiveDelegationForDelegate(tx, {
|
|
principalEmployeeId: task.assignee_id,
|
|
delegateEmployeeId: request.employee.id,
|
|
allowedScopes: ["all"],
|
|
});
|
|
|
|
if (delegation) {
|
|
return {
|
|
delegated: true,
|
|
delegationId: delegation.id,
|
|
principalId: delegation.principal_employee_id,
|
|
};
|
|
}
|
|
}
|
|
|
|
throw new AppError({
|
|
code: "PERM_002",
|
|
message: "不在审批范围/非当前处理人",
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
|
|
function withDelegationPayload(payload, assignment, delegateId) {
|
|
const nextPayload = payload ? { ...payload } : {};
|
|
|
|
if (assignment?.delegated) {
|
|
nextPayload.delegation_id = assignment.delegationId;
|
|
nextPayload.principal_id = assignment.principalId;
|
|
nextPayload.delegate_id = delegateId;
|
|
}
|
|
|
|
return nextPayload;
|
|
}
|
|
|
|
async function loadWorkflowInstanceForTask(tx, instanceId) {
|
|
const workflowInstance = await tx.workflowInstance.findUnique({
|
|
where: { id: instanceId },
|
|
select: {
|
|
id: true,
|
|
entity_id: true,
|
|
current_step_id: true,
|
|
context_data: true,
|
|
workflow_definition: {
|
|
select: {
|
|
definition_json: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!workflowInstance) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
return workflowInstance;
|
|
}
|
|
|
|
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 cancelOtherPendingTasks(tx, workflowInstanceId, taskId, actorId) {
|
|
await tx.workflowTask.updateMany({
|
|
where: {
|
|
instance_id: workflowInstanceId,
|
|
status: "pending",
|
|
NOT: { id: taskId },
|
|
},
|
|
data: {
|
|
status: "cancelled",
|
|
completed_at: new Date(),
|
|
completed_by: actorId,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function ensureTransferChainNotExceeded(tx, taskId) {
|
|
const transferCount = await tx.workflowHistory.count({
|
|
where: {
|
|
task_id: taskId,
|
|
action: "transfer",
|
|
},
|
|
});
|
|
|
|
if (transferCount >= 3) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { transfer: "chain_limit_exceeded" },
|
|
});
|
|
}
|
|
|
|
return transferCount;
|
|
}
|
|
|
|
async function ensureTransferTargetEligible(tx, toApproverId) {
|
|
const target = await tx.employee.findUnique({
|
|
where: { id: toApproverId },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
roles: {
|
|
select: {
|
|
role_code: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!target || target.status !== "active") {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { toApproverId: "invalid_target" },
|
|
});
|
|
}
|
|
|
|
const roleCodes = target.roles.map((item) => item.role_code);
|
|
const permissionCodes = resolvePermissionsForRoles(roleCodes);
|
|
if (!hasPermission(permissionCodes, "expense.approve")) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { toApproverId: "target_not_approver" },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function workflowRoutes(fastify) {
|
|
fastify.get(
|
|
"/workflow/tasks",
|
|
{
|
|
preHandler: fastify.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const query = parseTaskListQuery(request.query);
|
|
const skip = (query.page - 1) * query.pageSize;
|
|
|
|
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
|
|
|
const where = {
|
|
status: query.status,
|
|
OR: assigneeFilters,
|
|
};
|
|
|
|
const [tasks, total] = await Promise.all([
|
|
fastify.prisma.workflowTask.findMany({
|
|
where,
|
|
orderBy: { created_at: "desc" },
|
|
skip,
|
|
take: query.pageSize,
|
|
select: {
|
|
id: true,
|
|
instance_id: true,
|
|
step_id: true,
|
|
step_name: true,
|
|
assignee_id: true,
|
|
assignee_role: true,
|
|
status: true,
|
|
outcome: true,
|
|
created_at: true,
|
|
completed_at: true,
|
|
workflow_instance: {
|
|
select: {
|
|
entity_type: true,
|
|
entity_id: true,
|
|
current_status: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
fastify.prisma.workflowTask.count({ where }),
|
|
]);
|
|
|
|
return sendData(reply, request, {
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
total,
|
|
items: tasks.map(mapTaskSummary),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/workflow/tasks/:taskId",
|
|
{
|
|
preHandler: fastify.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const taskId = parseTaskId(request.params?.taskId);
|
|
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
|
|
|
const task = await fastify.prisma.workflowTask.findFirst({
|
|
where: {
|
|
id: taskId,
|
|
OR: assigneeFilters,
|
|
},
|
|
select: {
|
|
id: true,
|
|
instance_id: true,
|
|
step_id: true,
|
|
step_name: true,
|
|
assignee_id: true,
|
|
assignee_role: true,
|
|
status: true,
|
|
outcome: true,
|
|
comment: true,
|
|
created_at: true,
|
|
completed_at: true,
|
|
workflow_instance: {
|
|
select: {
|
|
id: true,
|
|
current_status: true,
|
|
current_step_id: true,
|
|
started_at: true,
|
|
completed_at: true,
|
|
reimbursement_form: {
|
|
select: {
|
|
id: true,
|
|
form_number: true,
|
|
status: true,
|
|
total_amount: true,
|
|
reimbursement_date: true,
|
|
description: true,
|
|
owner: {
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
},
|
|
},
|
|
submitter: {
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!task) {
|
|
const existing = await fastify.prisma.workflowTask.findUnique({
|
|
where: { id: taskId },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (existing) {
|
|
throw new AppError({
|
|
code: "PERM_002",
|
|
message: "不在审批范围/非当前处理人",
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
let actionable = false;
|
|
if (task.status === "pending") {
|
|
try {
|
|
await ensureTaskAssignableToCurrentEmployee(fastify.prisma, task, request);
|
|
actionable = true;
|
|
} catch (error) {
|
|
if (error instanceof AppError && error.code === "PERM_002") {
|
|
actionable = false;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
const historyItems = await fastify.prisma.workflowHistory.findMany({
|
|
where: { instance_id: task.instance_id },
|
|
orderBy: { created_at: "asc" },
|
|
select: {
|
|
id: true,
|
|
task_id: true,
|
|
actor_id: true,
|
|
action: true,
|
|
comment: true,
|
|
payload: true,
|
|
created_at: true,
|
|
actor: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
employee_no: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
task: {
|
|
id: task.id,
|
|
instanceId: task.instance_id,
|
|
stepId: task.step_id,
|
|
stepName: task.step_name,
|
|
assigneeId: task.assignee_id,
|
|
assigneeRole: task.assignee_role,
|
|
status: task.status,
|
|
outcome: task.outcome,
|
|
comment: task.comment,
|
|
createdAt: isoOrNull(task.created_at),
|
|
completedAt: isoOrNull(task.completed_at),
|
|
},
|
|
workflowInstance: task.workflow_instance
|
|
? {
|
|
id: task.workflow_instance.id,
|
|
currentStatus: task.workflow_instance.current_status,
|
|
currentStepId: task.workflow_instance.current_step_id,
|
|
startedAt: isoOrNull(task.workflow_instance.started_at),
|
|
completedAt: isoOrNull(task.workflow_instance.completed_at),
|
|
}
|
|
: null,
|
|
reimbursement: task.workflow_instance?.reimbursement_form
|
|
? {
|
|
id: task.workflow_instance.reimbursement_form.id,
|
|
formNumber: task.workflow_instance.reimbursement_form.form_number,
|
|
status: task.workflow_instance.reimbursement_form.status,
|
|
reimbursementDate:
|
|
task.workflow_instance.reimbursement_form.reimbursement_date
|
|
?.toISOString?.()
|
|
?.slice(0, 10) ?? null,
|
|
totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount),
|
|
description: task.workflow_instance.reimbursement_form.description,
|
|
owner: task.workflow_instance.reimbursement_form.owner
|
|
? {
|
|
id: task.workflow_instance.reimbursement_form.owner.id,
|
|
employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no,
|
|
name: task.workflow_instance.reimbursement_form.owner.name,
|
|
}
|
|
: null,
|
|
submitter: task.workflow_instance.reimbursement_form.submitter
|
|
? {
|
|
id: task.workflow_instance.reimbursement_form.submitter.id,
|
|
employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no,
|
|
name: task.workflow_instance.reimbursement_form.submitter.name,
|
|
}
|
|
: null,
|
|
}
|
|
: null,
|
|
allowedActions: listAllowedActionsForTask(request, task, actionable),
|
|
history: historyItems.map((item) => ({
|
|
id: item.id,
|
|
taskId: item.task_id,
|
|
actorId: item.actor_id,
|
|
actorName: item.actor?.name ?? "",
|
|
actorEmployeeNo: item.actor?.employee_no ?? "",
|
|
action: item.action,
|
|
comment: item.comment,
|
|
payload: item.payload,
|
|
createdAt: isoOrNull(item.created_at),
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/workflow/approvers",
|
|
{
|
|
preHandler: fastify.requirePermission("expense.transfer"),
|
|
},
|
|
async (request, reply) => {
|
|
const query = parseApproverListQuery(request.query);
|
|
const skip = (query.page - 1) * query.pageSize;
|
|
|
|
const where = {
|
|
status: "active",
|
|
id: { not: request.employee.id },
|
|
roles: {
|
|
some: {
|
|
role_code: { in: APPROVER_ROLE_CODES },
|
|
},
|
|
},
|
|
...(query.keyword
|
|
? {
|
|
OR: [
|
|
{ name: { contains: query.keyword, mode: "insensitive" } },
|
|
{ email: { contains: query.keyword, mode: "insensitive" } },
|
|
{ employee_no: { contains: query.keyword, mode: "insensitive" } },
|
|
],
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
const [items, total] = await Promise.all([
|
|
fastify.prisma.employee.findMany({
|
|
where,
|
|
orderBy: { name: "asc" },
|
|
skip,
|
|
take: query.pageSize,
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
email: true,
|
|
roles: {
|
|
select: {
|
|
role_code: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
fastify.prisma.employee.count({ where }),
|
|
]);
|
|
|
|
return sendData(reply, request, {
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
total,
|
|
items: items.map((item) => ({
|
|
id: item.id,
|
|
employeeNo: item.employee_no,
|
|
name: item.name,
|
|
email: item.email,
|
|
roleCodes: item.roles.map((role) => role.role_code),
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/workflow/tasks/:taskId/approve",
|
|
{
|
|
preHandler: fastify.requirePermission("expense.approve"),
|
|
},
|
|
async (request, reply) => {
|
|
const taskId = parseTaskId(request.params?.taskId);
|
|
let result = null;
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
const task = await lockWorkflowTask(tx, taskId);
|
|
ensurePendingTask(task);
|
|
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
|
|
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
|
const steps = getWorkflowStepsSnapshot(workflowInstance);
|
|
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
|
|
if (currentStepIndex < 0) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { workflow: "current_step_not_found" },
|
|
});
|
|
}
|
|
|
|
const now = new Date();
|
|
await tx.workflowTask.update({
|
|
where: { id: taskId },
|
|
data: {
|
|
status: "completed",
|
|
outcome: "approved",
|
|
completed_at: now,
|
|
completed_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
const { actionable, skipped } = await resolveActionableStep({
|
|
tx,
|
|
steps,
|
|
startIndex: currentStepIndex + 1,
|
|
ownerId: workflowInstance.context_data?.owner_id,
|
|
logger: fastify.log,
|
|
});
|
|
|
|
await createSkipHistoryRecords(
|
|
tx,
|
|
workflowInstance.id,
|
|
request.employee.id,
|
|
workflowInstance.context_data?.owner_id,
|
|
skipped,
|
|
);
|
|
|
|
if (actionable) {
|
|
const nextTask = 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.workflowInstance.update({
|
|
where: { id: workflowInstance.id },
|
|
data: {
|
|
current_step_id: actionable.step.id,
|
|
current_status: "pending",
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.workflowHistory.create({
|
|
data: {
|
|
instance_id: workflowInstance.id,
|
|
task_id: taskId,
|
|
actor_id: request.employee.id,
|
|
action: "approve",
|
|
payload: withDelegationPayload(
|
|
{
|
|
from_step_id: task.step_id,
|
|
to_step_id: actionable.step.id,
|
|
},
|
|
assignment,
|
|
request.employee.id,
|
|
),
|
|
},
|
|
});
|
|
|
|
result = {
|
|
taskId,
|
|
outcome: "approved",
|
|
workflowInstanceId: workflowInstance.id,
|
|
nextTaskId: nextTask.id,
|
|
reimbursementStatus: "reviewing",
|
|
};
|
|
return;
|
|
}
|
|
|
|
await tx.workflowInstance.update({
|
|
where: { id: workflowInstance.id },
|
|
data: {
|
|
current_status: "approved",
|
|
completed_at: now,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.reimbursementForm.update({
|
|
where: { id: workflowInstance.entity_id },
|
|
data: {
|
|
status: "approved",
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.workflowHistory.create({
|
|
data: {
|
|
instance_id: workflowInstance.id,
|
|
task_id: taskId,
|
|
actor_id: request.employee.id,
|
|
action: "approve",
|
|
payload: withDelegationPayload(
|
|
{
|
|
from_step_id: task.step_id,
|
|
terminal: true,
|
|
},
|
|
assignment,
|
|
request.employee.id,
|
|
),
|
|
},
|
|
});
|
|
|
|
result = {
|
|
taskId,
|
|
outcome: "approved",
|
|
workflowInstanceId: workflowInstance.id,
|
|
nextTaskId: null,
|
|
reimbursementStatus: "approved",
|
|
};
|
|
});
|
|
|
|
return sendData(reply, request, result);
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/workflow/tasks/:taskId/reject",
|
|
{
|
|
preHandler: fastify.requirePermission("expense.approve"),
|
|
},
|
|
async (request, reply) => {
|
|
const taskId = parseTaskId(request.params?.taskId);
|
|
const comment = parseRequiredComment(request.body);
|
|
let result = null;
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
const task = await lockWorkflowTask(tx, taskId);
|
|
ensurePendingTask(task);
|
|
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
|
|
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
|
const now = new Date();
|
|
|
|
await tx.workflowTask.update({
|
|
where: { id: taskId },
|
|
data: {
|
|
status: "rejected",
|
|
outcome: "rejected",
|
|
comment,
|
|
completed_at: now,
|
|
completed_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await cancelOtherPendingTasks(tx, workflowInstance.id, taskId, request.employee.id);
|
|
|
|
await tx.workflowInstance.update({
|
|
where: { id: workflowInstance.id },
|
|
data: {
|
|
current_status: "rejected",
|
|
completed_at: now,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.reimbursementForm.update({
|
|
where: { id: workflowInstance.entity_id },
|
|
data: {
|
|
status: "rejected",
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.workflowHistory.create({
|
|
data: {
|
|
instance_id: workflowInstance.id,
|
|
task_id: taskId,
|
|
actor_id: request.employee.id,
|
|
action: "reject",
|
|
comment,
|
|
payload: withDelegationPayload(
|
|
{
|
|
from_step_id: task.step_id,
|
|
},
|
|
assignment,
|
|
request.employee.id,
|
|
),
|
|
},
|
|
});
|
|
|
|
result = {
|
|
taskId,
|
|
outcome: "rejected",
|
|
workflowInstanceId: workflowInstance.id,
|
|
reimbursementStatus: "rejected",
|
|
};
|
|
});
|
|
|
|
return sendData(reply, request, result);
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/workflow/tasks/:taskId/return",
|
|
{
|
|
preHandler: fastify.requirePermission("expense.approve"),
|
|
},
|
|
async (request, reply) => {
|
|
const taskId = parseTaskId(request.params?.taskId);
|
|
const comment = parseRequiredComment(request.body);
|
|
let result = null;
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
const task = await lockWorkflowTask(tx, taskId);
|
|
ensurePendingTask(task);
|
|
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
|
|
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
|
const now = new Date();
|
|
|
|
await tx.workflowTask.update({
|
|
where: { id: taskId },
|
|
data: {
|
|
status: "completed",
|
|
outcome: "returned",
|
|
comment,
|
|
completed_at: now,
|
|
completed_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await cancelOtherPendingTasks(tx, workflowInstance.id, taskId, request.employee.id);
|
|
|
|
await tx.workflowInstance.update({
|
|
where: { id: workflowInstance.id },
|
|
data: {
|
|
current_status: "terminated",
|
|
completed_at: now,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.reimbursementForm.update({
|
|
where: { id: workflowInstance.entity_id },
|
|
data: {
|
|
status: "draft",
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.workflowHistory.create({
|
|
data: {
|
|
instance_id: workflowInstance.id,
|
|
task_id: taskId,
|
|
actor_id: request.employee.id,
|
|
action: "return",
|
|
comment,
|
|
payload: withDelegationPayload(
|
|
{
|
|
from_step_id: task.step_id,
|
|
},
|
|
assignment,
|
|
request.employee.id,
|
|
),
|
|
},
|
|
});
|
|
|
|
result = {
|
|
taskId,
|
|
outcome: "returned",
|
|
workflowInstanceId: workflowInstance.id,
|
|
reimbursementStatus: "draft",
|
|
};
|
|
});
|
|
|
|
return sendData(reply, request, result);
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/workflow/tasks/:taskId/transfer",
|
|
{
|
|
preHandler: fastify.requirePermission("expense.transfer"),
|
|
},
|
|
async (request, reply) => {
|
|
const taskId = parseTaskId(request.params?.taskId);
|
|
const { toApproverId, reason } = parseTransferBody(request.body);
|
|
let result = null;
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
const task = await lockWorkflowTask(tx, taskId);
|
|
ensurePendingTask(task);
|
|
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
|
|
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
|
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
|
|
await ensureTransferTargetEligible(tx, toApproverId);
|
|
|
|
if (toApproverId === request.employee.id) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { transfer: "same_assignee_not_allowed" },
|
|
});
|
|
}
|
|
|
|
await tx.workflowTask.update({
|
|
where: { id: taskId },
|
|
data: {
|
|
assignee_id: toApproverId,
|
|
assignee_role: null,
|
|
},
|
|
});
|
|
|
|
const transfer = await tx.approvalTransfer.create({
|
|
data: {
|
|
reimbursement_id: workflowInstance.entity_id,
|
|
from_approver_id: request.employee.id,
|
|
to_approver_id: toApproverId,
|
|
reason,
|
|
status: "active",
|
|
created_by: request.employee.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
await tx.workflowHistory.create({
|
|
data: {
|
|
instance_id: workflowInstance.id,
|
|
task_id: taskId,
|
|
actor_id: request.employee.id,
|
|
action: "transfer",
|
|
comment: reason,
|
|
payload: withDelegationPayload(
|
|
{
|
|
from_approver_id: request.employee.id,
|
|
to_approver_id: toApproverId,
|
|
reason,
|
|
transfer_count: transferCount + 1,
|
|
},
|
|
assignment,
|
|
request.employee.id,
|
|
),
|
|
},
|
|
});
|
|
|
|
result = {
|
|
taskId,
|
|
outcome: "transferred",
|
|
workflowInstanceId: workflowInstance.id,
|
|
reimbursementStatus: "reviewing",
|
|
transferId: transfer.id,
|
|
fromApproverId: request.employee.id,
|
|
toApproverId,
|
|
transferCount: transferCount + 1,
|
|
};
|
|
});
|
|
|
|
return sendData(reply, request, result);
|
|
},
|
|
);
|
|
}
|
|
|
|
module.exports = workflowRoutes;
|