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
这个提交包含在:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户