feat(server): 完成 P0+P1 后端能力(工作流、RBAC、付款归档)

P0 MVP:

- 认证:login/refresh/logout/forgot/reset + tokenVersion

- RBAC:角色到权限映射 + requirePermission

- 发票池:upload/bind/signed-url + 判重(VALID_003/VALID_005)

- 报销:create/view/submit + 单号生成

- 工作流:approve + department_manager 解析

P1 扩展:

- 动作:reject/return/withdraw(含 comment)

- transfer:任务转审 + 链路上限 3

- pay:approved -> paid + 发票归档 used_complete

- 动态审批链:approval_rules 驱动

回归脚本:

- smoke-test.sh / regression-dedup.sh / regression-workflow-actions.sh
这个提交包含在:
2026-02-07 16:43:31 +08:00
当前提交 f527633716
共修改 44 个文件,包含 11100 行新增和 0 行删除
+121
查看文件
@@ -0,0 +1,121 @@
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
require("dotenv/config");
const fastify = require("fastify");
const cors = require("@fastify/cors");
const cookie = require("@fastify/cookie");
const multipart = require("@fastify/multipart");
const { getConfig } = require("./config");
const authPlugin = require("./plugins/auth");
const prismaPlugin = require("./plugins/prisma");
const authRoutes = require("./routes/auth");
const meRoutes = require("./routes/me");
const reimbursementRoutes = require("./routes/reimbursements");
const invoicePoolRoutes = require("./routes/invoicePool");
const workflowRoutes = require("./routes/workflow");
const { AppError } = require("./utils/AppError");
async function buildApp() {
const config = getConfig();
const app = fastify({
logger: true,
trustProxy: true,
genReqId: () => crypto.randomUUID(),
});
app.decorate("config", config);
await fs.mkdir(config.storageDir, { recursive: true });
await app.register(cookie);
await app.register(multipart, {
attachFieldsToBody: false,
limits: {
fileSize: config.maxUploadSizeBytes,
files: 1,
},
});
const allowedOrigins = config.appOrigin
? config.appOrigin
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
await app.register(cors, {
credentials: true,
origin: (origin, cb) => {
if (!origin) return cb(null, true);
if (!allowedOrigins) return cb(null, true);
if (allowedOrigins.includes(origin)) return cb(null, true);
return cb(new Error("CORS origin not allowed"), false);
},
});
app.addHook("onRequest", async (request, reply) => {
reply.header("x-request-id", request.id);
});
app.setNotFoundHandler(async () => {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
});
app.setErrorHandler((error, request, reply) => {
if (error.code === "FST_REQ_FILE_TOO_LARGE") {
return reply.status(400).send({
requestId: request.id,
error: {
code: "VALID_001",
message: "参数校验失败",
details: { file: "file_too_large" },
},
});
}
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
app.log.error({ err: error, requestId: request.id }, "Unhandled error");
return reply.status(500).send({
requestId: request.id,
error: {
code: "SYS_001",
message: "服务器内部错误",
details: {},
},
});
});
await app.register(prismaPlugin);
await app.register(authPlugin);
app.get("/health", async (request, reply) => {
return reply.send({ requestId: request.id, data: { ok: true } });
});
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(meRoutes, { prefix: "/api" });
await app.register(reimbursementRoutes, { prefix: "/api" });
await app.register(invoicePoolRoutes, { prefix: "/api" });
await app.register(workflowRoutes, { prefix: "/api" });
return app;
}
module.exports = { buildApp };
+48
查看文件
@@ -0,0 +1,48 @@
const path = require("node:path");
const ONE_DAY_SECONDS = 24 * 60 * 60;
function requiredEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env: ${name}`);
}
return value;
}
function getConfig() {
const nodeEnv = process.env.NODE_ENV ?? "development";
const serverRoot = path.resolve(__dirname, "..");
const storageDir = process.env.STORAGE_DIR ?? "generated/storage";
return {
nodeEnv,
port: Number(process.env.PORT ?? 3000),
host: process.env.HOST ?? "0.0.0.0",
appOrigin: process.env.APP_ORIGIN ?? "",
jwtSecret: requiredEnv("JWT_SECRET"),
accessTokenTtl: "15m",
refreshTtlSeconds: 7 * ONE_DAY_SECONDS,
refreshCookieName: "refresh_token",
cookieSecure:
process.env.COOKIE_SECURE != null
? process.env.COOKIE_SECURE === "true"
: nodeEnv === "production",
passwordResetCodeTtlSeconds: Number(
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * 60,
),
devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true",
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
storageDir: path.resolve(serverRoot, storageDir),
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
};
}
module.exports = { getConfig };
+62
查看文件
@@ -0,0 +1,62 @@
const EMPLOYEE_BASE_PERMISSIONS = [
"expense.create",
"expense.view_own",
"expense.edit_own",
"expense.delete_own",
"expense.submit",
"expense.withdraw",
"invoice.upload",
"invoice.view_own",
"expense.proxy_manage",
];
const ROLE_PERMISSIONS = {
employee: EMPLOYEE_BASE_PERMISSIONS,
manager: [...EMPLOYEE_BASE_PERMISSIONS, "expense.approve", "expense.transfer", "expense.view_all"],
ceo: [...EMPLOYEE_BASE_PERMISSIONS, "expense.approve", "expense.transfer", "expense.view_all"],
finance: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
],
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
"expense.finance.override_duplicate",
"system.settings",
],
administration: ["expense.proxy_submit", "expense.view_own"],
hr: ["system.users", "expense.view_all"],
admin: ["*"],
};
function getRolePermissions(roleCode) {
return ROLE_PERMISSIONS[roleCode] ?? [];
}
function resolvePermissionsForRoles(roleCodes) {
const permissions = new Set();
for (const roleCode of roleCodes ?? []) {
for (const permission of getRolePermissions(roleCode)) {
permissions.add(permission);
}
}
return Array.from(permissions);
}
function hasPermission(permissionCodes, permission) {
return permissionCodes.includes("*") || permissionCodes.includes(permission);
}
module.exports = {
ROLE_PERMISSIONS,
resolvePermissionsForRoles,
hasPermission,
};
+15
查看文件
@@ -0,0 +1,15 @@
const { buildApp } = require("./app");
async function main() {
const app = await buildApp();
const { host, port } = app.config;
await app.listen({ host, port });
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
+105
查看文件
@@ -0,0 +1,105 @@
const fastifyJwt = require("@fastify/jwt");
const fp = require("fastify-plugin");
const { AppError } = require("../utils/AppError");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
async function authPlugin(fastify) {
const { jwtSecret, accessTokenTtl } = fastify.config;
await fastify.register(fastifyJwt, {
secret: jwtSecret,
});
fastify.decorateRequest("employee", null);
fastify.decorate("issueAccessToken", (employee) => {
return fastify.jwt.sign(
{
sub: employee.id,
tokenVersion: employee.token_version,
},
{ expiresIn: accessTokenTtl },
);
});
fastify.decorate("hasPermission", (employee, permissionCode) => {
return hasPermission(employee?.permissionCodes ?? [], permissionCode);
});
fastify.decorate("requirePermission", (permissionCode) => {
return async (request) => {
await fastify.authenticate(request);
if (!fastify.hasPermission(request.employee, permissionCode)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { permission: permissionCode },
});
}
};
});
fastify.decorate("authenticate", async (request) => {
try {
await request.jwtVerify();
} catch {
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
const employeeId = request.user?.sub;
const tokenVersion = request.user?.tokenVersion;
if (typeof employeeId !== "string" || typeof tokenVersion !== "number") {
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
status: true,
token_version: true,
department_id: true,
roles: { select: { role_code: true } },
},
});
if (!employee || employee.status !== "active") {
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
if (tokenVersion < employee.token_version) {
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
const roleCodes = employee.roles.map((item) => item.role_code);
const permissionCodes = resolvePermissionsForRoles(roleCodes);
request.employee = {
id: employee.id,
status: employee.status,
token_version: employee.token_version,
department_id: employee.department_id,
roleCodes,
permissionCodes,
};
});
}
module.exports = fp(authPlugin, { name: "auth" });
+16
查看文件
@@ -0,0 +1,16 @@
const { createPrismaClient } = require("../prismaClient");
const fp = require("fastify-plugin");
async function prismaPlugin(fastify) {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
fastify.decorate("prisma", prisma);
fastify.addHook("onClose", async (instance) => {
await instance.prisma.$disconnect();
await pool.end();
});
}
module.exports = fp(prismaPlugin, { name: "prisma" });
+19
查看文件
@@ -0,0 +1,19 @@
const { PrismaClient } = require("@prisma/client");
const { PrismaPg } = require("@prisma/adapter-pg");
const { Pool } = require("pg");
function createPrismaClient() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("Missing DATABASE_URL");
}
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool, { disposeExternalPool: false });
const prisma = new PrismaClient({ adapter });
return { prisma, pool };
}
module.exports = { createPrismaClient };
+528
查看文件
@@ -0,0 +1,528 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto");
const { validatePassword } = require("../utils/passwordPolicy");
const { sendData } = require("../utils/reply");
function getHeaderString(request, name) {
const value = request.headers[name];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function getRequestOrigin(request) {
const origin = getHeaderString(request, "origin");
if (origin) return origin;
const referer = getHeaderString(request, "referer");
if (!referer) return null;
try {
return new URL(referer).origin;
} catch {
return null;
}
}
function refreshCookieOptions(config) {
return {
httpOnly: true,
sameSite: "strict",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: config.refreshTtlSeconds,
};
}
async function authRoutes(fastify) {
const loginBodySchema = z.object({
identifier: z.string().min(3),
password: z.string().min(1),
});
fastify.post("/login", async (request, reply) => {
const parsed = loginBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const { identifier, password } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { OR: [{ phone: identifier }, { email: identifier }] },
select: {
id: true,
status: true,
password_hash: true,
token_version: true,
},
});
if (!employee) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
if (employee.status !== "active") {
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
const ok = await verifyPassword(password, employee.password_hash);
if (!ok) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
await fastify.prisma.$transaction(async (tx) => {
const activeSessions = await tx.authSession.findMany({
where: {
employee_id: employee.id,
revoked_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "asc" },
select: { id: true },
});
const maxSessions = 5;
const allowedExisting = maxSessions - 1;
if (activeSessions.length > allowedExisting) {
const revokeCount = activeSessions.length - allowedExisting;
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
await tx.authSession.updateMany({
where: { id: { in: idsToRevoke } },
data: { revoked_at: now },
});
}
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: refreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: expiresAt,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_LOGIN_SUCCESS",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
});
reply.setCookie(
fastify.config.refreshCookieName,
refreshToken,
refreshCookieOptions(fastify.config),
);
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, { accessToken });
});
fastify.post("/refresh", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken !== "string" || !refreshToken) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const session = await fastify.prisma.authSession.findFirst({
where: { refresh_token_hash: refreshTokenHash },
select: {
id: true,
employee_id: true,
device_fingerprint: true,
expires_at: true,
revoked_at: true,
},
});
if (!session) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.device_fingerprint !== deviceFingerprint) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const expectedOrigin = fastify.config.appOrigin;
const requestOrigin = getRequestOrigin(request);
if (expectedOrigin && requestOrigin && requestOrigin !== expectedOrigin) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "AUTH_REFRESH_ORIGIN_MISMATCH",
targetType: "employee",
targetId: session.employee_id,
extra: { expectedOrigin, requestOrigin },
}),
});
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.expires_at <= now) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.revoked_at) {
const rotatedReplacement = await fastify.prisma.authSession.findFirst({
where: {
employee_id: session.employee_id,
device_fingerprint: session.device_fingerprint,
revoked_at: null,
expires_at: { gt: now },
created_at: { gt: session.revoked_at },
},
select: { id: true },
});
if (rotatedReplacement) {
await fastify.prisma.$transaction(async (tx) => {
await tx.authSession.updateMany({
where: { employee_id: session.employee_id, revoked_at: null },
data: { revoked_at: now },
});
await tx.employee.update({
where: { id: session.employee_id },
data: { token_version: { increment: 1 } },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "REFRESH_REUSE_DETECTED",
targetType: "employee",
targetId: session.employee_id,
extra: {},
}),
});
});
}
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: session.employee_id },
select: { id: true, status: true, token_version: true },
});
if (!employee || employee.status !== "active") {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const newRefreshToken = randomRefreshToken();
const newRefreshTokenHash = sha256Hex(newRefreshToken);
const newExpiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.authSession.updateMany({
where: {
id: session.id,
revoked_at: null,
expires_at: { gt: now },
},
data: { revoked_at: now, last_seen_at: now },
});
if (updated.count !== 1) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "REFRESH_REUSE_DETECTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: newRefreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: newExpiresAt,
},
});
});
reply.setCookie(
fastify.config.refreshCookieName,
newRefreshToken,
refreshCookieOptions(fastify.config),
);
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, { accessToken });
});
fastify.post("/logout", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken === "string" && refreshToken) {
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const session = await fastify.prisma.authSession.findFirst({
where: { refresh_token_hash: refreshTokenHash },
select: { id: true, employee_id: true, revoked_at: true },
});
await fastify.prisma.authSession.updateMany({
where: { refresh_token_hash: refreshTokenHash, revoked_at: null },
data: { revoked_at: now, last_seen_at: now },
});
if (session?.employee_id) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "AUTH_LOGOUT",
targetType: "employee",
targetId: session.employee_id,
extra: {},
}),
});
}
}
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
return sendData(reply, request, {});
});
const forgotPasswordBodySchema = z.object({
email: z.string().email(),
});
fastify.post("/forgot-password", async (request, reply) => {
const parsed = forgotPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const { email } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { email },
select: { id: true, status: true },
});
if (!employee || employee.status !== "active") {
return sendData(reply, request, {});
}
const code = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
const codeHash = sha256Hex(code);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000);
await fastify.prisma.passwordResetCode.create({
data: {
employee_id: employee.id,
code_hash: codeHash,
expires_at: expiresAt,
created_ip: request.ip,
created_user_agent: getHeaderString(request, "user-agent"),
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_RESET_REQUESTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
if (fastify.config.devShowPasswordResetCode) {
fastify.log.info(
{ employeeId: employee.id, email, code, requestId: request.id },
"DEV password reset code",
);
}
return sendData(reply, request, {});
});
const resetPasswordBodySchema = z.object({
email: z.string().email(),
code: z.string().length(6),
newPassword: z.string().min(12),
});
fastify.post("/reset-password", async (request, reply) => {
const parsed = resetPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const { email, code, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword);
if (policyErrors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
}
const employee = await fastify.prisma.employee.findFirst({
where: { email },
select: { id: true, status: true },
});
if (!employee || employee.status !== "active") {
return sendData(reply, request, {});
}
const now = new Date();
const codeHash = sha256Hex(code);
const resetCode = await fastify.prisma.passwordResetCode.findFirst({
where: {
employee_id: employee.id,
code_hash: codeHash,
used_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "desc" },
select: { id: true },
});
if (!resetCode) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
}
const newPasswordHash = await hashPassword(newPassword);
await fastify.prisma.$transaction(async (tx) => {
await tx.passwordResetCode.update({
where: { id: resetCode.id },
data: { used_at: now },
});
await tx.employee.update({
where: { id: employee.id },
data: {
password_hash: newPasswordHash,
token_version: { increment: 1 },
},
});
await tx.authSession.updateMany({
where: { employee_id: employee.id, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_RESET_SUCCEEDED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
});
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
return sendData(reply, request, {});
});
}
module.exports = authRoutes;
+645
查看文件
@@ -0,0 +1,645 @@
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const fsSync = require("node:fs");
const path = require("node:path");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { compactDateString } = require("../utils/date");
const { sha256Hex } = require("../utils/crypto");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { toMoneyString } = require("../utils/workflow");
const MIME_ALLOWLIST = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]);
const bindBodySchema = z.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
});
const DEDUP_CONSTRAINT_NAMES = new Set([
"invoice_pool_file_sha256_dedup_ux",
"invoice_pool_invoice_code_number_dedup_ux",
]);
const DEDUP_FIELD_NAMES = new Set(["file_sha256", "invoice_code", "invoice_number"]);
function tokenizeMetaString(value) {
if (typeof value !== "string") {
return [];
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return [];
}
const splitTokens = normalized
.split(/[^a-z0-9_]+/i)
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
return [normalized, ...splitTokens];
}
function normalizeConstraintMeta(value) {
if (Array.isArray(value)) {
return value
.flatMap((item) => tokenizeMetaString(String(item)))
.filter(Boolean);
}
if (typeof value === "string") {
return tokenizeMetaString(value);
}
return [];
}
function collectFieldTokens(meta) {
return new Set(
[
...normalizeConstraintMeta(meta.target),
...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.constraint?.fields),
].filter((token) => DEDUP_FIELD_NAMES.has(token)),
);
}
function containsDedupConstraintName(metaTokens) {
const constraints = Array.from(DEDUP_CONSTRAINT_NAMES);
return metaTokens.some((token) => constraints.some((constraint) => token.includes(constraint)));
}
function isFieldBasedDedup(fieldTokens) {
if (fieldTokens.size === 1 && fieldTokens.has("file_sha256")) {
return true;
}
if (
fieldTokens.size === 2 &&
fieldTokens.has("invoice_code") &&
fieldTokens.has("invoice_number")
) {
return true;
}
return false;
}
function isInvoiceDedupConflict(error) {
if (!error || error.code !== "P2002") {
return false;
}
const meta = error.meta ?? {};
const metaTokens = [
...normalizeConstraintMeta(meta.constraint),
...normalizeConstraintMeta(meta.target),
...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.originalMessage),
];
if (containsDedupConstraintName(metaTokens)) {
return true;
}
const fieldTokens = collectFieldTokens(meta);
return isFieldBasedDedup(fieldTokens);
}
function ensureInvoiceReadableByUser(request, invoice) {
const roleCodes = request.employee.roleCodes;
if (
roleCodes.includes("admin") ||
roleCodes.includes("finance") ||
roleCodes.includes("finance_mgr")
) {
return true;
}
if (
invoice.owner_employee_id === request.employee.id ||
invoice.uploaded_by_employee_id === request.employee.id
) {
return true;
}
if (invoice.linked_reimbursement) {
const reimbursement = invoice.linked_reimbursement;
if (
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id
) {
return true;
}
}
return false;
}
function sanitizeFilename(fileName) {
if (!fileName) return "file";
return fileName.replace(/[\r\n"]/g, "_");
}
async function invoicePoolRoutes(fastify) {
fastify.post(
"/invoice-pool/upload",
{
preHandler: fastify.requirePermission("invoice.upload"),
},
async (request, reply) => {
let filePart = null;
const fields = {};
try {
for await (const part of request.parts()) {
if (part.type === "file") {
if (filePart) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "only_single_file_supported" },
});
}
filePart = part;
} else {
fields[part.fieldname] = part.value;
}
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
});
}
throw error;
}
if (!filePart) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "missing" },
});
}
if (!MIME_ALLOWLIST.has(filePart.mimetype)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "unsupported_mime_type" },
});
}
const ownerEmployeeId = fields.owner_employee_id ?? request.employee.id;
if (ownerEmployeeId !== request.employee.id) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { owner_employee_id: "cross_owner_upload_not_allowed_in_mvp" },
});
}
const ocrType = fields.ocr_type ?? "general";
if (!OCR_TYPE_ALLOWLIST.has(ocrType)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { ocr_type: "invalid" },
});
}
const chunks = [];
let fileSize = 0;
for await (const chunk of filePart.file) {
fileSize += chunk.length;
if (fileSize > fastify.config.maxUploadSizeBytes) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
});
}
chunks.push(chunk);
}
if (fileSize === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "empty_file" },
});
}
const fileBuffer = Buffer.concat(chunks);
const fileSha256 = sha256Hex(fileBuffer);
const datePrefix = compactDateString(fastify.config.appTimezone);
const extension = path.extname(filePart.filename ?? "").toLowerCase();
const storagePath = path.join(datePrefix, `${crypto.randomUUID()}${extension}`);
const absoluteStoragePath = path.resolve(fastify.config.storageDir, storagePath);
await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true });
await fs.writeFile(absoluteStoragePath, fileBuffer);
const invoice = await fastify.prisma.invoicePool.create({
data: {
owner_employee_id: ownerEmployeeId,
uploaded_by_employee_id: request.employee.id,
storage_path: storagePath,
original_name: filePart.filename ?? "upload",
mime_type: filePart.mimetype,
file_size: fileSize,
file_sha256: fileSha256,
ocr_type: ocrType,
status: "available",
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
file_sha256: true,
},
});
return sendData(reply, request, {
invoicePoolId: invoice.id,
status: invoice.status,
fileSha256: invoice.file_sha256,
});
},
);
fastify.post(
"/invoice-pool/:id/signed-url",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
linked_reimbursement_id: true,
linked_reimbursement: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let canAccess = ensureInvoiceReadableByUser(request, invoice);
if (!canAccess && invoice.linked_reimbursement_id) {
const pendingTask = await fastify.prisma.workflowTask.findFirst({
where: {
status: "pending",
workflow_instance: {
entity_type: "reimbursement",
entity_id: invoice.linked_reimbursement_id,
current_status: "pending",
},
OR: [
{ assignee_id: request.employee.id },
{ assignee_role: { in: request.employee.roleCodes } },
],
},
select: { id: true },
});
canAccess = Boolean(pendingTask);
}
if (!canAccess) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
const token = signPayload(
{
invoiceId: invoice.id,
exp: expiresAt,
},
fastify.config.jwtSecret,
);
const origin =
fastify.config.appOrigin ||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
const signedUrl = `${origin}/api/invoice-pool/${invoice.id}/file?token=${encodeURIComponent(token)}`;
return sendData(reply, request, {
signedUrl,
expiresAt: new Date(expiresAt).toISOString(),
});
},
);
fastify.get("/invoice-pool/:id/file", async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
const token = request.query?.token;
if (!parsedInvoiceId.success || typeof token !== "string") {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const payload = verified.payload;
if (
payload?.invoiceId !== parsedInvoiceId.data ||
typeof payload?.exp !== "number" ||
payload.exp <= Date.now()
) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
storage_path: true,
mime_type: true,
original_name: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const storageRoot = path.resolve(fastify.config.storageDir);
const absolutePath = path.resolve(storageRoot, invoice.storage_path);
if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
try {
await fs.access(absolutePath);
} catch {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
reply.type(invoice.mime_type || "application/octet-stream");
reply.header(
"content-disposition",
`inline; filename=\"${sanitizeFilename(invoice.original_name)}\"`,
);
return reply.send(fsSync.createReadStream(absolutePath));
});
fastify.post(
"/invoice-pool/:id/bind",
{
preHandler: fastify.requirePermission("invoice.upload"),
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const parsedBody = bindBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
}
const invoiceId = parsedInvoiceId.data;
const { reimbursementId, linkedItemId } = parsedBody.data;
try {
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (![
"draft",
"rejected",
].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
}
if (linkedItemId) {
const linkedItem = await tx.reimbursementItem.findFirst({
where: {
id: linkedItemId,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "item_not_in_reimbursement" },
});
}
}
const invoiceRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
status: true,
linked_reimbursement_id: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (invoice.status === "bound" || invoice.linked_reimbursement_id) {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
statusCode: 409,
});
}
if (invoice.status !== "available") {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
statusCode: 409,
details: { status: invoice.status },
});
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: linkedItemId ?? null,
updated_by: request.employee.id,
},
});
});
} catch (error) {
if (isInvoiceDedupConflict(error)) {
throw new AppError({
code: "VALID_003",
message: "发票判重命中",
statusCode: 409,
});
}
if (error?.code === "P2002") {
fastify.log.warn(
{ meta: error?.meta ?? {} },
"Unexpected P2002 not from invoice dedup constraints",
);
}
throw error;
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
linked_reimbursement_id: true,
linked_item_id: true,
total_amount: true,
},
});
return sendData(reply, request, {
id: invoice.id,
status: invoice.status,
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
totalAmount: toMoneyString(invoice.total_amount),
});
},
);
}
module.exports = invoicePoolRoutes;
+42
查看文件
@@ -0,0 +1,42 @@
const { sendData } = require("../utils/reply");
async function meRoutes(fastify) {
fastify.get(
"/me",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const employee = await fastify.prisma.employee.findUnique({
where: { id: request.employee.id },
select: {
id: true,
employee_no: true,
name: true,
phone: true,
email: true,
department_id: true,
direct_manager_id: true,
status: true,
roles: { select: { role_code: true } },
},
});
const roleCodes = (employee?.roles ?? []).map((r) => r.role_code);
return sendData(reply, request, {
id: employee?.id ?? request.employee.id,
employeeNo: employee?.employee_no ?? "",
name: employee?.name ?? "",
phone: employee?.phone ?? "",
email: employee?.email ?? "",
departmentId: employee?.department_id ?? "",
directManagerId: employee?.direct_manager_id ?? null,
status: employee?.status ?? "active",
roleCodes,
});
},
);
}
module.exports = meRoutes;
+724
查看文件
@@ -0,0 +1,724 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
toMoneyString,
} = require("../utils/workflow");
const UUID_SCHEMA = z.string().uuid();
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
return;
}
if (!fastify.hasPermission(request.employee, "expense.view_own")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isOwn) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
}
function formatFormNumber(sequence, timezone) {
const datePart = compactDateString(timezone);
const serial = String(sequence.current_number).padStart(sequence.number_length, "0");
return `${sequence.prefix}${sequence.separator}${datePart}${sequence.separator}${serial}`;
}
function parseWithdrawBody(body) {
const bodySchema = z.object({
comment: z.string().trim().min(1).max(500).optional(),
});
const parsed = bodySchema.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseReimbursementId(id) {
const parsed = UUID_SCHEMA.safeParse(id);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
return parsed.data;
}
function ensureNonNegativeAmount(totalAmount) {
const asNumber = Number(totalAmount);
if (!Number.isFinite(asNumber)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { total_amount: "invalid" },
});
}
if (asNumber < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { total_amount: "negative_not_allowed" },
});
}
}
async function loadApprovalRuleForAmount(tx, totalAmount) {
const amountString = toMoneyString(totalAmount);
const approvalRule = await tx.approvalRule.findFirst({
where: {
enabled: true,
min_amount: { lte: amountString },
OR: [{ max_amount: null }, { max_amount: { gt: amountString } }],
},
orderBy: { priority: "asc" },
select: {
id: true,
level1_role: true,
level2_role: true,
level3_role: true,
priority: true,
},
});
if (!approvalRule) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "missing_approval_rule" },
});
}
return approvalRule;
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().max(500).optional(),
});
fastify.post(
"/reimbursements",
{
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
const parsed = createBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const reimbursementDate =
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
const reimbursement = await fastify.prisma.reimbursementForm.create({
data: {
submitter_id: request.employee.id,
owner_id: request.employee.id,
department_id: request.employee.department_id,
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: parsed.data.description ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: true,
form_number: true,
},
});
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
reimbursementDate,
status: reimbursement.status,
description: reimbursement.description,
totalAmount: toMoneyString(reimbursement.total_amount),
});
},
);
fastify.get(
"/reimbursements/:id",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
submitter_id: true,
owner_id: true,
department_id: true,
reimbursement_date: true,
total_amount: true,
status: true,
description: true,
owner: {
select: { id: true, employee_no: true, name: true },
},
submitter: {
select: { id: true, employee_no: true, name: true },
},
invoice_pool_items: {
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
},
},
workflow_instances: {
where: { current_status: "pending" },
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_step_id: true,
workflow_tasks: {
where: { status: "pending" },
take: 1,
select: {
id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
},
},
},
},
},
});
if (!reimbursement) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
submitterId: reimbursement.submitter_id,
ownerId: reimbursement.owner_id,
departmentId: reimbursement.department_id,
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
description: reimbursement.description,
owner: reimbursement.owner,
submitter: reimbursement.submitter,
invoices: reimbursement.invoice_pool_items.map((item) => ({
id: item.id,
status: item.status,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
totalAmount: toMoneyString(item.total_amount),
})),
pendingWorkflow: pendingInstance
? {
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
task: pendingTask,
}
: null,
});
},
);
fastify.post(
"/reimbursements/:id/submit",
{
preHandler: fastify.requirePermission("expense.submit"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let submitResult = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, status, form_number, total_amount
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
ensureOwnOrCanViewAll(fastify, request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_submit_status" },
});
}
ensureNonNegativeAmount(reimbursement.total_amount);
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
current_status: "pending",
},
select: { id: true },
});
if (activeInstance) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { submit: "already_in_review" },
});
}
const boundInvoiceCount = await tx.invoicePool.count({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
});
if (boundInvoiceCount < 1) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { invoices: "bound_invoice_required" },
});
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: {
entity_type: "reimbursement",
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 approvalRule = await loadApprovalRuleForAmount(tx, reimbursement.total_amount);
const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: reimbursement.owner_id,
logger: fastify.log,
});
if (!actionable) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
workflow: "no_actionable_approver",
},
});
}
let formNumber = reimbursement.form_number;
if (!formNumber) {
const sequence = await tx.documentSequence.upsert({
where: { document_type: "reimbursement" },
update: {
current_number: { increment: 1 },
updated_by: request.employee.id,
},
create: {
document_type: "reimbursement",
prefix: "BX",
date_format: "YYYYMMDD",
separator: "-",
number_length: 4,
reset_period: "none",
current_number: 1,
updated_by: request.employee.id,
},
select: {
prefix: true,
separator: true,
number_length: true,
current_number: true,
},
});
formNumber = formatFormNumber(sequence, fastify.config.appTimezone);
}
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
form_number: formNumber,
status: "reviewing",
updated_by: request.employee.id,
},
});
const workflowInstance = await tx.workflowInstance.create({
data: {
workflow_id: workflowDefinition.id,
entity_type: "reimbursement",
entity_id: reimbursementId,
submitter_id: request.employee.id,
current_status: "pending",
current_step_id: actionable.step.id,
context_data: {
owner_id: reimbursement.owner_id,
department_id: reimbursement.department_id,
workflow_steps_snapshot: workflowSteps,
approval_rule_id: approvalRule.id,
},
context_department_id: reimbursement.department_id,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
current_step_id: true,
},
});
for (const skippedStep of skipped) {
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
actor_id: request.employee.id,
action: "skip",
payload: {
step_id: skippedStep.step.id,
reason: skippedStep.reason,
owner_id: reimbursement.owner_id,
depth_checked: skippedStep.depthChecked,
},
},
});
}
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,
assignee_id: true,
assignee_role: 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,
approval_rule_id: approvalRule.id,
skipped_step_count: skipped.length,
},
},
});
submitResult = {
reimbursementId,
formNumber,
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
status: "reviewing",
};
});
return sendData(reply, request, submitResult);
},
);
fastify.post(
"/reimbursements/:id/withdraw",
{
preHandler: fastify.requirePermission("expense.withdraw"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const { comment } = parseWithdrawBody(request.body);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isApplicant =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isApplicant) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { withdraw: "only_owner_or_submitter" },
});
}
if (reimbursement.status !== "reviewing") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_withdraw_status" },
});
}
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
current_status: "pending",
},
orderBy: { created_at: "desc" },
select: { id: true },
});
if (!activeInstance) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { withdraw: "no_pending_instance" },
});
}
const processedTaskCount = await tx.workflowTask.count({
where: {
instance_id: activeInstance.id,
status: { in: ["completed", "rejected"] },
},
});
if (processedTaskCount > 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { withdraw: "already_processed_by_approver" },
});
}
const now = new Date();
await tx.workflowTask.updateMany({
where: {
instance_id: activeInstance.id,
status: "pending",
},
data: {
status: "cancelled",
completed_at: now,
completed_by: request.employee.id,
},
});
await tx.workflowInstance.update({
where: { id: activeInstance.id },
data: {
current_status: "cancelled",
completed_at: now,
updated_by: request.employee.id,
},
});
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: activeInstance.id,
actor_id: request.employee.id,
action: "withdraw",
comment: comment ?? null,
payload: {
reimbursement_id: reimbursementId,
},
},
});
response = {
reimbursementId,
outcome: "withdrawn",
workflowInstanceId: activeInstance.id,
reimbursementStatus: "draft",
};
});
return sendData(reply, request, response);
},
);
fastify.post(
"/reimbursements/:id/pay",
{
preHandler: fastify.requirePermission("expense.finance.pay"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
if (!["approved", "paid"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_pay_status" },
});
}
const now = new Date();
const archivedResult = await tx.invoicePool.updateMany({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
data: {
status: "archived",
archived_reason: "used_complete",
archived_at: now,
updated_by: request.employee.id,
},
});
if (reimbursement.status === "approved") {
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
status: "paid",
updated_by: request.employee.id,
},
});
}
response = {
reimbursementId,
outcome: "paid",
reimbursementStatus: "paid",
// archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain.
archivedInvoiceCount: archivedResult.count,
};
});
return sendData(reply, request, response);
},
);
}
module.exports = reimbursementRoutes;
+659
查看文件
@@ -0,0 +1,659 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow");
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),
});
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;
}
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" },
});
}
}
function ensureTaskAssignableToCurrentEmployee(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)) {
throw new AppError({
code: "PERM_002",
message: "不在审批范围/非当前处理人",
statusCode: 403,
});
}
}
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 querySchema = z.object({
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
});
const parsedQuery = querySchema.safeParse(request.query ?? {});
if (!parsedQuery.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedQuery.error.flatten(),
});
}
const status = parsedQuery.data.status ?? "pending";
const roleFilters = request.employee.roleCodes.length
? [{ assignee_role: { in: request.employee.roleCodes } }]
: [];
const tasks = await fastify.prisma.workflowTask.findMany({
where: {
status,
OR: [{ assignee_id: request.employee.id }, ...roleFilters],
},
orderBy: { created_at: "desc" },
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,
},
},
},
});
return sendData(reply, request, {
items: tasks,
});
},
);
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);
ensureTaskAssignableToCurrentEmployee(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: {
from_step_id: task.step_id,
to_step_id: actionable.step.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: {
from_step_id: task.step_id,
terminal: true,
},
},
});
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);
ensureTaskAssignableToCurrentEmployee(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: {
from_step_id: task.step_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);
ensureTaskAssignableToCurrentEmployee(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: {
from_step_id: task.step_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);
ensureTaskAssignableToCurrentEmployee(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: {
from_approver_id: request.employee.id,
to_approver_id: toApproverId,
reason,
transfer_count: transferCount + 1,
},
},
});
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;
+233
查看文件
@@ -0,0 +1,233 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const ENABLED_EXPENSE_TYPES = [
{ code: "travel_airfare", name: "机票", category: "travel" },
{ code: "travel_train", name: "火车票", category: "travel" },
{ code: "travel_taxi", name: "打车费", category: "travel" },
{ code: "travel_hotel", name: "住宿费", category: "travel" },
{ code: "travel_bus", name: "市内交通", category: "travel" },
{ code: "travel_meal", name: "差旅餐补", category: "travel" },
{ code: "travel_fuel", name: "油费", category: "travel" },
{ code: "travel_toll", name: "过路费", category: "travel" },
{ code: "travel_parking", name: "停车费", category: "travel" },
{ code: "travel_visa", name: "签证费", category: "travel" },
{ code: "rd_materials", name: "研发材料", category: "rd" },
{ code: "rd_testing", name: "测试费用", category: "rd" },
{ code: "rd_prototype", name: "样机制作", category: "rd" },
{ code: "rd_software", name: "软件订阅", category: "rd" },
{ code: "rd_external_service", name: "外部技术服务", category: "rd" },
{ code: "production_raw_material", name: "生产原材料", category: "production" },
{ code: "production_processing", name: "加工费", category: "production" },
{ code: "production_quality", name: "质量检测", category: "production" },
{ code: "production_maintenance", name: "设备维护", category: "production" },
{ code: "production_energy", name: "生产能耗", category: "production" },
{ code: "office_stationery", name: "文具耗材", category: "office" },
{ code: "office_supplies", name: "办公用品", category: "office" },
{ code: "office_property_fee", name: "物业费用", category: "office" },
{ code: "office_communication", name: "通讯费用", category: "office" },
{ code: "office_postal", name: "邮寄快递", category: "office" },
{ code: "office_cleaning", name: "保洁服务", category: "office" },
{ code: "business_client_meal", name: "客户招待", category: "business" },
{ code: "business_gift", name: "商务礼品", category: "business" },
{ code: "business_event", name: "活动执行", category: "business" },
{ code: "business_marketing", name: "营销投放", category: "business" },
{ code: "business_training", name: "培训会议", category: "business" },
{ code: "assets_equipment", name: "设备采购", category: "assets" },
{ code: "assets_furniture", name: "家具采购", category: "assets" },
{ code: "assets_it_hardware", name: "IT 硬件", category: "assets" },
{ code: "utilities", name: "水电费", category: "other" },
{ code: "rent", name: "房租租赁费", category: "other" },
{ code: "other_misc", name: "其他杂项", category: "other" },
];
const DISABLED_LEGACY_EXPENSE_TYPES = [
{ code: "transport", name: "legacy-交通费", category: "travel" },
{ code: "meal", name: "legacy-餐费", category: "business" },
{ code: "rd", name: "legacy-研发费", category: "rd" },
];
const DEFAULT_APPROVAL_RULES = [
{
name: "小额",
min_amount: "0",
max_amount: "500",
level1_role: "manager",
level2_role: "finance",
level3_role: null,
priority: 10,
},
{
name: "中额",
min_amount: "500",
max_amount: "5000",
level1_role: "manager",
level2_role: "finance_mgr",
level3_role: null,
priority: 20,
},
{
name: "大额",
min_amount: "5000",
max_amount: null,
level1_role: "manager",
level2_role: "ceo",
level3_role: "finance_mgr",
priority: 30,
},
];
const DEFAULT_WORKFLOW_DEFINITION = {
steps: [
{
id: "ceo_review",
name: "总经理审批",
type: "approval",
assignee_rule: {
type: "role",
value: "ceo",
},
},
],
};
async function seedExpenseTypes(prisma) {
const now = new Date();
const allTypes = [
...ENABLED_EXPENSE_TYPES.map((item) => ({ ...item, enabled: true })),
...DISABLED_LEGACY_EXPENSE_TYPES.map((item) => ({ ...item, enabled: false })),
];
for (const [index, item] of allTypes.entries()) {
await prisma.expenseType.upsert({
where: { code: item.code },
update: {
name: item.name,
description: null,
category: item.category,
sort_order: index + 1,
account_code_default: null,
is_system_default: true,
enabled: item.enabled,
updated_at: now,
updated_by: null,
},
create: {
code: item.code,
name: item.name,
description: null,
category: item.category,
sort_order: index + 1,
account_code_default: null,
is_system_default: true,
enabled: item.enabled,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
},
});
}
}
async function seedApprovalRules(prisma) {
await prisma.approvalRule.deleteMany({});
await prisma.approvalRule.createMany({
data: DEFAULT_APPROVAL_RULES.map((item) => ({
...item,
enabled: true,
created_by: null,
updated_by: null,
})),
});
}
async function seedDocumentSequence(prisma) {
await prisma.documentSequence.upsert({
where: { document_type: "reimbursement" },
update: {
prefix: "BX",
date_format: "YYYYMMDD",
separator: "-",
number_length: 4,
reset_period: "none",
},
create: {
document_type: "reimbursement",
prefix: "BX",
date_format: "YYYYMMDD",
separator: "-",
number_length: 4,
reset_period: "none",
current_number: 0,
updated_by: null,
},
});
}
async function seedWorkflowDefinition(prisma) {
await prisma.workflowDefinition.updateMany({
where: {
entity_type: "reimbursement",
},
data: {
is_active: false,
},
});
await prisma.workflowDefinition.upsert({
where: {
entity_type_version: {
entity_type: "reimbursement",
version: 1,
},
},
update: {
name: "报销审批流程(MVP)",
definition_json: DEFAULT_WORKFLOW_DEFINITION,
is_active: true,
created_by: null,
},
create: {
name: "报销审批流程(MVP)",
entity_type: "reimbursement",
definition_json: DEFAULT_WORKFLOW_DEFINITION,
version: 1,
is_active: true,
created_by: null,
},
});
}
async function main() {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await seedExpenseTypes(prisma);
await seedApprovalRules(prisma);
await seedDocumentSequence(prisma);
await seedWorkflowDefinition(prisma);
// eslint-disable-next-line no-console
console.log("Seeded config data:");
// eslint-disable-next-line no-console
console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`);
// eslint-disable-next-line no-console
console.log("- approval rules: 3");
// eslint-disable-next-line no-console
console.log("- document sequence: reimbursement(reset_period=none)");
// eslint-disable-next-line no-console
console.log("- workflow definition: reimbursement v1 (1-step role=ceo)");
} finally {
await prisma.$disconnect();
await pool.end();
}
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});
+274
查看文件
@@ -0,0 +1,274 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const { hashPassword } = require("../utils/crypto");
async function ensureDepartment(prisma, payload) {
const now = new Date();
const existing = await prisma.department.findFirst({
where: {
name: payload.name,
parent_id: payload.parent_id ?? null,
},
});
if (existing) {
return prisma.department.update({
where: { id: existing.id },
data: {
parent_id: payload.parent_id ?? null,
archived_at: null,
updated_at: now,
},
});
}
return prisma.department.create({
data: {
name: payload.name,
parent_id: payload.parent_id ?? null,
created_at: now,
updated_at: now,
},
});
}
async function ensureEmployee(prisma, payload) {
const now = new Date();
const passwordHash = await hashPassword(payload.password);
const existing = await prisma.employee.findFirst({
where: { email: payload.email },
select: { id: true },
});
if (existing) {
const employee = await prisma.employee.update({
where: { id: existing.id },
data: {
employee_no: payload.employeeNo,
name: payload.name,
phone: payload.phone,
department_id: payload.departmentId,
title: payload.title,
direct_manager_id: payload.directManagerId,
hire_date: payload.hireDate,
status: "active",
bank_account_encrypted: "ENC_DEV_ONLY",
bank_name: "DEV_BANK",
password_hash: passwordHash,
updated_at: now,
},
select: { id: true, email: true },
});
await prisma.employeeRole.deleteMany({
where: { employee_id: employee.id },
});
await prisma.employeeRole.createMany({
data: payload.roles.map((roleCode) => ({
employee_id: employee.id,
role_code: roleCode,
created_at: now,
})),
skipDuplicates: true,
});
return employee;
}
const employee = await prisma.employee.create({
data: {
employee_no: payload.employeeNo,
name: payload.name,
phone: payload.phone,
email: payload.email,
department_id: payload.departmentId,
title: payload.title,
direct_manager_id: payload.directManagerId,
hire_date: payload.hireDate,
status: "active",
bank_account_encrypted: "ENC_DEV_ONLY",
bank_name: "DEV_BANK",
password_hash: passwordHash,
created_at: now,
updated_at: now,
},
select: { id: true, email: true },
});
await prisma.employeeRole.createMany({
data: payload.roles.map((roleCode) => ({
employee_id: employee.id,
role_code: roleCode,
created_at: now,
})),
skipDuplicates: true,
});
return employee;
}
async function main() {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
const hireDate = new Date("2020-01-01");
try {
const rootDepartment = await ensureDepartment(prisma, { name: "公司" });
const managementDepartment = await ensureDepartment(prisma, {
name: "管理部",
parent_id: rootDepartment.id,
});
const financeDepartment = await ensureDepartment(prisma, {
name: "财务部",
parent_id: rootDepartment.id,
});
const hrDepartment = await ensureDepartment(prisma, {
name: "人事部",
parent_id: rootDepartment.id,
});
const administrationDepartment = await ensureDepartment(prisma, {
name: "行政部",
parent_id: rootDepartment.id,
});
const rdDepartment = await ensureDepartment(prisma, {
name: "研发部",
parent_id: rootDepartment.id,
});
const ceo = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "E0001",
name: "CEO",
phone: process.env.SEED_CEO_PHONE ?? "13900000000",
email: process.env.SEED_CEO_EMAIL ?? "[email protected]",
password: process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234",
departmentId: rootDepartment.id,
title: "总经理",
directManagerId: null,
roles: ["ceo", "employee"],
hireDate,
});
const admin = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "E0000",
name: "Admin",
phone: process.env.SEED_ADMIN_PHONE ?? "13800000000",
email: process.env.SEED_ADMIN_EMAIL ?? "[email protected]",
password: process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234",
departmentId: managementDepartment.id,
title: "系统管理员",
directManagerId: ceo.id,
roles: ["admin", "employee"],
hireDate,
});
const manager = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "E0100",
name: "Manager",
phone: process.env.SEED_MANAGER_PHONE ?? "13700000000",
email: process.env.SEED_MANAGER_EMAIL ?? "[email protected]",
password: process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234",
departmentId: rdDepartment.id,
title: "研发经理",
directManagerId: ceo.id,
roles: ["manager", "employee"],
hireDate,
});
const employee = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "E0101",
name: "Employee",
phone: process.env.SEED_EMPLOYEE_PHONE ?? "13600000000",
email: process.env.SEED_EMPLOYEE_EMAIL ?? "[email protected]",
password: process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234",
departmentId: rdDepartment.id,
title: "研发工程师",
directManagerId: manager.id,
roles: ["employee"],
hireDate,
});
const finance = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "E0200",
name: "Finance",
phone: process.env.SEED_FINANCE_PHONE ?? "13500000000",
email: process.env.SEED_FINANCE_EMAIL ?? "[email protected]",
password: process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234",
departmentId: financeDepartment.id,
title: "财务专员",
directManagerId: ceo.id,
roles: ["finance", "employee"],
hireDate,
});
const financeMgr = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "E0201",
name: "Finance Manager",
phone: process.env.SEED_FINANCE_MGR_PHONE ?? "13400000000",
email: process.env.SEED_FINANCE_MGR_EMAIL ?? "[email protected]",
password: process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234",
departmentId: financeDepartment.id,
title: "财务主管",
directManagerId: ceo.id,
roles: ["finance", "finance_mgr", "employee"],
hireDate,
});
const hr = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "E0300",
name: "HR",
phone: process.env.SEED_HR_PHONE ?? "13300000000",
email: process.env.SEED_HR_EMAIL ?? "[email protected]",
password: process.env.SEED_HR_PASSWORD ?? "HrPassword1234",
departmentId: hrDepartment.id,
title: "人事专员",
directManagerId: ceo.id,
roles: ["hr"],
hireDate,
});
const administration = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "E0400",
name: "Administration",
phone: process.env.SEED_ADMINISTRATION_PHONE ?? "13200000000",
email: process.env.SEED_ADMINISTRATION_EMAIL ?? "[email protected]",
password: process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234",
departmentId: administrationDepartment.id,
title: "行政专员",
directManagerId: manager.id,
roles: ["administration"],
hireDate,
});
// eslint-disable-next-line no-console
console.log("Seeded dev users:");
// eslint-disable-next-line no-console
console.log(`- CEO: ${ceo.email} / ${process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Admin: ${admin.email} / ${process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Manager: ${manager.email} / ${process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Employee: ${employee.email} / ${process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Finance: ${finance.email} / ${process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Finance Manager: ${financeMgr.email} / ${process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- HR: ${hr.email} / ${process.env.SEED_HR_PASSWORD ?? "HrPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Administration: ${administration.email} / ${process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234"}`);
} finally {
await prisma.$disconnect();
await pool.end();
}
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});
+15
查看文件
@@ -0,0 +1,15 @@
class AppError extends Error {
/**
* @param {{ code: string; message: string; statusCode: number; details?: any }} params
*/
constructor({ code, message, statusCode, details }) {
super(message);
this.name = "AppError";
this.code = code;
this.statusCode = statusCode;
this.details = details ?? {};
}
}
module.exports = { AppError };
+17
查看文件
@@ -0,0 +1,17 @@
function buildAuditLogData(request, params) {
const userAgent = request.headers["user-agent"];
return {
request_id: request.id,
actor_id: params.actorId,
action: params.action,
target_type: params.targetType,
target_id: params.targetId ?? null,
ip_address: request.ip,
user_agent: typeof userAgent === "string" ? userAgent : null,
extra: params.extra ?? {},
};
}
module.exports = { buildAuditLogData };
+27
查看文件
@@ -0,0 +1,27 @@
const crypto = require("node:crypto");
const bcrypt = require("bcryptjs");
function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
function randomRefreshToken() {
return crypto.randomBytes(32).toString("base64url");
}
async function hashPassword(password) {
const saltRounds = 12;
return bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash);
}
module.exports = {
hashPassword,
randomRefreshToken,
sha256Hex,
verifyPassword,
};
+24
查看文件
@@ -0,0 +1,24 @@
function currentDateString(timeZone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return formatter.format(new Date());
}
function compactDateString(timeZone) {
return currentDateString(timeZone).replaceAll("-", "");
}
function parseDateOnlyString(value) {
return new Date(`${value}T00:00:00.000Z`);
}
module.exports = {
compactDateString,
currentDateString,
parseDateOnlyString,
};
+21
查看文件
@@ -0,0 +1,21 @@
function validatePassword(password) {
const errors = [];
if (typeof password !== "string" || password.length < 12) {
errors.push("密码长度必须 ≥ 12");
}
if (!/[a-z]/.test(password)) {
errors.push("密码必须包含小写字母");
}
if (!/[A-Z]/.test(password)) {
errors.push("密码必须包含大写字母");
}
if (!/[0-9]/.test(password)) {
errors.push("密码必须包含数字");
}
return errors;
}
module.exports = { validatePassword };
+6
查看文件
@@ -0,0 +1,6 @@
function sendData(reply, request, data) {
return reply.send({ requestId: request.id, data });
}
module.exports = { sendData };
+45
查看文件
@@ -0,0 +1,45 @@
const crypto = require("node:crypto");
function toBase64Url(value) {
const source = typeof value === "string" ? value : JSON.stringify(value);
return Buffer.from(source).toString("base64url");
}
function fromBase64Url(value) {
return Buffer.from(value, "base64url").toString("utf8");
}
function signPayload(payload, secret) {
const body = toBase64Url(payload);
const signature = crypto.createHmac("sha256", secret).update(body).digest("base64url");
return `${body}.${signature}`;
}
function verifyToken(token, secret) {
if (typeof token !== "string") {
return { ok: false, reason: "invalid_format" };
}
const segments = token.split(".");
if (segments.length !== 2) {
return { ok: false, reason: "invalid_format" };
}
const [body, signature] = segments;
const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
if (signature !== expected) {
return { ok: false, reason: "invalid_signature" };
}
try {
const payload = JSON.parse(fromBase64Url(body));
return { ok: true, payload };
} catch {
return { ok: false, reason: "invalid_payload" };
}
}
module.exports = {
signPayload,
verifyToken,
};
+305
查看文件
@@ -0,0 +1,305 @@
const { AppError } = require("./AppError");
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const MAX_MANAGER_DEPTH = 10;
function parseWorkflowSteps(definitionJson) {
const steps = definitionJson?.steps;
if (!Array.isArray(steps) || steps.length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_steps" },
});
}
return steps;
}
function buildWorkflowStepsFromApprovalRule(approvalRule) {
if (!approvalRule || typeof approvalRule !== "object") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_approval_rule" },
});
}
const roleCodes = [
approvalRule.level1_role,
approvalRule.level2_role,
approvalRule.level3_role,
].filter(Boolean);
if (roleCodes.length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "empty_approval_chain" },
});
}
return roleCodes.map((roleCode, index) => ({
id: `approval_${index + 1}_${roleCode}`,
name: `审批节点${index + 1}`,
type: "approval",
assignee_rule:
roleCode === "manager"
? { type: "department_manager" }
: { type: "role", value: roleCode },
}));
}
async function resolveDepartmentManagerAssignee({ tx, ownerId, logger }) {
let currentId = ownerId;
const visited = new Set();
for (let depth = 1; depth <= MAX_MANAGER_DEPTH; depth += 1) {
if (visited.has(currentId)) {
logger?.warn?.(
{ ownerId, currentId, reason: "cycle_detected" },
"Circular manager reference detected",
);
return {
assigneeId: null,
reason: "cycle_detected",
depthChecked: depth - 1,
};
}
visited.add(currentId);
const employee = await tx.employee.findUnique({
where: { id: currentId },
select: {
id: true,
direct_manager_id: true,
},
});
if (!employee) {
return {
assigneeId: null,
reason: "chain_broken",
depthChecked: depth,
};
}
const managerId = employee.direct_manager_id;
if (!managerId) {
return {
assigneeId: null,
reason: "no_manager_found",
depthChecked: depth,
};
}
if (managerId === currentId) {
logger?.warn?.(
{ ownerId, currentId, reason: "self_loop" },
"Self-loop manager reference detected",
);
return {
assigneeId: null,
reason: "self_loop",
depthChecked: depth,
};
}
if (managerId === ownerId) {
currentId = managerId;
continue;
}
if (visited.has(managerId)) {
logger?.warn?.(
{ ownerId, currentId: managerId, reason: "cycle_detected" },
"Circular manager reference detected",
);
return {
assigneeId: null,
reason: "cycle_detected",
depthChecked: depth,
};
}
return {
assigneeId: managerId,
reason: null,
depthChecked: depth,
};
}
logger?.warn?.(
{ ownerId, currentId, reason: "max_depth_exceeded" },
"Manager resolution reached max depth",
);
return {
assigneeId: null,
reason: "max_depth_exceeded",
depthChecked: MAX_MANAGER_DEPTH,
};
}
async function resolveAssigneeFromRule(rule, options = {}) {
if (!rule || typeof rule !== "object") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_assignee_rule" },
});
}
if (rule.type === "role") {
if (typeof rule.value !== "string" || !rule.value) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_role_assignee" },
});
}
return {
assigneeId: null,
assigneeRole: rule.value,
skipReason: null,
depthChecked: 0,
};
}
if (rule.type === "specific_user") {
if (typeof rule.value !== "string" || !UUID_REGEX.test(rule.value)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_specific_user_assignee" },
});
}
return {
assigneeId: rule.value,
assigneeRole: null,
skipReason: null,
depthChecked: 0,
};
}
if (rule.type === "department_manager") {
const ownerId = options.ownerId;
const tx = options.tx;
if (!ownerId || !UUID_REGEX.test(ownerId) || !tx) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_department_manager_context" },
});
}
const managerResult = await resolveDepartmentManagerAssignee({
tx,
ownerId,
logger: options.logger,
});
if (!managerResult.assigneeId) {
return {
assigneeId: null,
assigneeRole: null,
skipReason: managerResult.reason,
depthChecked: managerResult.depthChecked,
};
}
return {
assigneeId: managerResult.assigneeId,
assigneeRole: null,
skipReason: null,
depthChecked: managerResult.depthChecked,
};
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "unsupported_assignee_rule_type" },
});
}
async function resolveActionableStep({ tx, steps, startIndex, ownerId, logger }) {
const skipped = [];
for (let index = startIndex; index < steps.length; index += 1) {
const step = steps[index];
const resolved = await resolveAssigneeFromRule(step.assignee_rule, {
tx,
ownerId,
logger,
});
if (resolved.assigneeId || resolved.assigneeRole) {
return {
actionable: {
index,
step,
assigneeId: resolved.assigneeId,
assigneeRole: resolved.assigneeRole,
},
skipped,
};
}
if (resolved.skipReason) {
skipped.push({
index,
step,
reason: resolved.skipReason,
depthChecked: resolved.depthChecked,
});
continue;
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_resolved_assignee" },
});
}
return {
actionable: null,
skipped,
};
}
function getWorkflowStepsSnapshot(workflowInstance) {
const snapshot = workflowInstance?.context_data?.workflow_steps_snapshot;
if (Array.isArray(snapshot) && snapshot.length > 0) {
return parseWorkflowSteps({ steps: snapshot });
}
return parseWorkflowSteps(workflowInstance?.workflow_definition?.definition_json);
}
function toMoneyString(value) {
if (value == null) return null;
return typeof value === "string" ? value : value.toString();
}
module.exports = {
buildWorkflowStepsFromApprovalRule,
getWorkflowStepsSnapshot,
parseWorkflowSteps,
resolveActionableStep,
resolveAssigneeFromRule,
resolveDepartmentManagerAssignee,
toMoneyString,
};