feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)

这个提交包含在:
2026-02-10 11:28:28 +08:00
父节点 bec21e1b38
当前提交 6bc09f557b
共修改 33 个文件,包含 3467 行新增和 13 行删除
+280
查看文件
@@ -0,0 +1,280 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const BUDGET_CREATE_SCHEMA = z.object({
code: z.string().trim().min(2).max(32),
name: z.string().trim().min(1).max(120),
departmentId: z.string().uuid().nullable().optional(),
periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/),
warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(),
});
const BUDGET_PATCH_SCHEMA = z
.object({
name: z.string().trim().min(1).max(120).optional(),
allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/).optional(),
warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(["active", "archived"]).optional(),
})
.strict();
const BUDGET_LIST_QUERY_SCHEMA = z.object({
status: z.enum(["active", "archived"]).optional(),
departmentId: z.string().uuid().optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
function toDate(value) {
return new Date(`${value}T00:00:00.000Z`);
}
function toMoneyString(value) {
if (value == null) {
return "0.00";
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00";
}
function mapBudget(item) {
const allocated = Number(item.allocated_amount ?? 0);
const used = Number(item.used_amount ?? 0);
const remaining = allocated - used;
return {
id: item.id,
code: item.code,
name: item.name,
departmentId: item.department_id ?? null,
status: item.status,
periodStart: item.period_start?.toISOString?.()?.slice(0, 10) ?? null,
periodEnd: item.period_end?.toISOString?.()?.slice(0, 10) ?? null,
allocatedAmount: toMoneyString(item.allocated_amount),
usedAmount: toMoneyString(item.used_amount),
remainingAmount: toMoneyString(remaining),
warningThresholdPercent: item.warning_threshold_percent,
archivedAt: item.archived_at?.toISOString?.() ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function parseBudgetId(value) {
const parsed = z.string().uuid().safeParse(value);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
return parsed.data;
}
async function budgetsRoutes(fastify) {
fastify.get(
"/budgets",
{
preHandler: fastify.requirePermission("budget.view"),
},
async (request, reply) => {
const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const query = parsed.data;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(query.departmentId ? { department_id: query.departmentId } : {}),
};
const [items, total] = await Promise.all([
fastify.prisma.budgetPool.findMany({
where,
orderBy: [{ period_start: "desc" }, { created_at: "desc" }],
skip,
take: pageSize,
}),
fastify.prisma.budgetPool.count({ where }),
]);
return sendData(reply, request, {
page,
pageSize,
total,
items: items.map(mapBudget),
});
},
);
fastify.post(
"/budgets",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const body = parsed.data;
const start = toDate(body.periodStart);
const end = toDate(body.periodEnd);
if (start > end) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { periodEnd: "must_be_after_periodStart" },
});
}
const created = await fastify.prisma.budgetPool.create({
data: {
code: body.code.toUpperCase(),
name: body.name,
department_id: body.departmentId ?? null,
status: "active",
period_start: start,
period_end: end,
allocated_amount: body.allocatedAmount,
used_amount: "0.00",
warning_threshold_percent: body.warningThresholdPercent ?? 80,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_CREATED",
targetType: "budget_pool",
targetId: created.id,
extra: {
code: created.code,
allocatedAmount: created.allocated_amount,
},
}),
});
return sendData(reply, request, mapBudget(created));
},
);
fastify.patch(
"/budgets/:id",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const budgetId = parseBudgetId(request.params?.id);
const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
if (!Object.keys(parsed.data).length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty_patch" },
});
}
const patch = parsed.data;
const updated = await fastify.prisma.budgetPool.update({
where: { id: budgetId },
data: {
...(patch.name != null ? { name: patch.name } : {}),
...(patch.allocatedAmount != null ? { allocated_amount: patch.allocatedAmount } : {}),
...(patch.warningThresholdPercent != null
? { warning_threshold_percent: patch.warningThresholdPercent }
: {}),
...(patch.status ? { status: patch.status } : {}),
...(patch.status === "archived" ? { archived_at: new Date() } : {}),
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_UPDATED",
targetType: "budget_pool",
targetId: budgetId,
extra: {
patch,
},
}),
});
return sendData(reply, request, mapBudget(updated));
},
);
fastify.post(
"/budgets/:id/archive",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const budgetId = parseBudgetId(request.params?.id);
const updated = await fastify.prisma.budgetPool.update({
where: { id: budgetId },
data: {
status: "archived",
archived_at: new Date(),
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_ARCHIVED",
targetType: "budget_pool",
targetId: budgetId,
extra: {
code: updated.code,
},
}),
});
return sendData(reply, request, mapBudget(updated));
},
);
}
module.exports = budgetsRoutes;
+278
查看文件
@@ -0,0 +1,278 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const QUERY_SCHEMA = z.object({
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
departmentId: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(30).optional(),
});
function parseQuery(query) {
const parsed = QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const dateFrom = parsed.data.dateFrom ? new Date(`${parsed.data.dateFrom}T00:00:00.000Z`) : null;
const dateTo = parsed.data.dateTo ? new Date(`${parsed.data.dateTo}T23:59:59.999Z`) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
return {
dateFrom,
dateTo,
departmentId: parsed.data.departmentId ?? null,
limit: parsed.data.limit ?? 8,
};
}
function canViewAll(request) {
return request.employee.roleCodes.some((code) =>
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
);
}
function buildReimbursementWhere(request, query) {
const where = {
...(query.dateFrom || query.dateTo
? {
reimbursement_date: {
...(query.dateFrom ? { gte: query.dateFrom } : {}),
...(query.dateTo ? { lte: query.dateTo } : {}),
},
}
: {}),
...(query.departmentId ? { department_id: query.departmentId } : {}),
};
if (canViewAll(request)) {
return where;
}
return {
...where,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
function moneyString(value) {
if (value == null) {
return "0.00";
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return "0.00";
}
return numeric.toFixed(2);
}
async function dashboardRoutes(fastify) {
fastify.get(
"/dashboard/expense-overview",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const where = buildReimbursementWhere(request, query);
const [totalCount, totalAmountAgg, paidCount, paidAmountAgg, reviewingCount] = await Promise.all([
fastify.prisma.reimbursementForm.count({ where }),
fastify.prisma.reimbursementForm.aggregate({
where,
_sum: { total_amount: true },
}),
fastify.prisma.reimbursementForm.count({
where: { ...where, status: "paid" },
}),
fastify.prisma.reimbursementForm.aggregate({
where: { ...where, status: "paid" },
_sum: { total_amount: true },
}),
fastify.prisma.reimbursementForm.count({
where: { ...where, status: "reviewing" },
}),
]);
return sendData(reply, request, {
totalCount,
totalAmount: moneyString(totalAmountAgg._sum.total_amount),
paidCount,
paidAmount: moneyString(paidAmountAgg._sum.total_amount),
reviewingCount,
});
},
);
fastify.get(
"/dashboard/trends",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const where = buildReimbursementWhere(request, query);
const groups = await fastify.prisma.reimbursementForm.groupBy({
where,
by: ["reimbursement_date"],
_sum: {
total_amount: true,
},
_count: {
_all: true,
},
orderBy: {
reimbursement_date: "asc",
},
});
const points = groups.map((item) => ({
date: item.reimbursement_date?.toISOString?.().slice(0, 10) ?? "unknown",
amount: moneyString(item._sum.total_amount),
count: item._count._all,
}));
return sendData(reply, request, {
points,
});
},
);
fastify.get(
"/dashboard/top-categories",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const where = buildReimbursementWhere(request, query);
const grouped = await fastify.prisma.reimbursementItem.groupBy({
where: {
reimbursement_form: where,
},
by: ["expense_type_id"],
_sum: {
amount: true,
},
_count: {
_all: true,
},
orderBy: {
_sum: {
amount: "desc",
},
},
take: query.limit,
});
const typeIds = grouped.map((item) => item.expense_type_id);
const expenseTypes = typeIds.length
? await fastify.prisma.expenseType.findMany({
where: { id: { in: typeIds } },
select: { id: true, code: true, name: true },
})
: [];
const typeMap = new Map(expenseTypes.map((item) => [item.id, item]));
const items = grouped.map((item) => {
const expenseType = typeMap.get(item.expense_type_id);
return {
code: expenseType?.code ?? "unknown",
name: expenseType?.name ?? "未知",
amount: moneyString(item._sum.amount),
count: item._count._all,
};
});
return sendData(reply, request, {
items,
});
},
);
fastify.get(
"/dashboard/approval-efficiency",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const where = buildReimbursementWhere(request, query);
const approvedForms = await fastify.prisma.reimbursementForm.findMany({
where: {
...where,
status: {
in: ["approved", "paid"],
},
},
select: {
id: true,
created_at: true,
},
});
const formIdSet = approvedForms.map((item) => item.id);
const instances = formIdSet.length
? await fastify.prisma.workflowInstance.findMany({
where: {
entity_type: "reimbursement",
reimbursement_id: { in: formIdSet },
completed_at: { not: null },
current_status: "approved",
},
select: {
reimbursement_id: true,
completed_at: true,
},
})
: [];
const completedMap = new Map(
instances.map((item) => [item.reimbursement_id, item.completed_at]),
);
const durations = approvedForms
.map((form) => {
const completedAt = completedMap.get(form.id);
if (!completedAt) {
return null;
}
return Math.max(0, completedAt.getTime() - form.created_at.getTime());
})
.filter((item) => item != null);
const totalMs = durations.reduce((sum, value) => sum + value, 0);
const avgMs = durations.length ? Math.round(totalMs / durations.length) : 0;
return sendData(reply, request, {
approvedCount: durations.length,
averageHours: Number((avgMs / 3_600_000).toFixed(2)),
});
},
);
}
module.exports = dashboardRoutes;
+171 -1
查看文件
@@ -41,6 +41,14 @@ const AUDIT_EXPORT_QUERY_SCHEMA = z.object({
limit: z.coerce.number().int().min(1).max(50000).optional(),
});
const DASHBOARD_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
departmentId: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
function csvCell(value) {
if (value == null) return "";
@@ -113,6 +121,127 @@ function buildViewAllScopeWhere(request) {
};
}
function toDateRange(query) {
const dateFrom = query.dateFrom ? new Date(`${query.dateFrom}T00:00:00.000Z`) : null;
const dateTo = query.dateTo ? new Date(`${query.dateTo}T23:59:59.999Z`) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
return { dateFrom, dateTo };
}
function buildDashboardWhere(request, query) {
const { dateFrom, dateTo } = toDateRange(query);
const dateWhere =
dateFrom || dateTo
? {
reimbursement_date: {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {}),
},
}
: {};
const departmentWhere = query.departmentId ? { department_id: query.departmentId } : {};
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {
...dateWhere,
...departmentWhere,
};
}
return {
...dateWhere,
...departmentWhere,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
yield "\ufeff";
yield csvRow([
"formNumber",
"status",
"departmentId",
"reimbursementDate",
"totalAmount",
"ownerName",
"submitterName",
"createdAt",
"id",
]);
const batchSize = 500;
let remaining = limit;
let cursorCreatedAt = null;
let cursorId = null;
while (remaining > 0) {
const take = Math.min(batchSize, remaining);
const rows = await prisma.reimbursementForm.findMany({
where: {
...where,
...(cursorCreatedAt
? {
OR: [
{ created_at: { lt: cursorCreatedAt } },
{ created_at: cursorCreatedAt, id: { lt: cursorId } },
],
}
: {}),
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take,
select: {
id: true,
form_number: true,
status: true,
department_id: true,
reimbursement_date: true,
total_amount: true,
created_at: true,
owner: { select: { name: true } },
submitter: { select: { name: true } },
},
});
if (!rows.length) {
break;
}
for (const item of rows) {
yield csvRow([
item.form_number ?? "",
item.status,
item.department_id,
item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? "",
item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
item.owner?.name ?? "",
item.submitter?.name ?? "",
item.created_at?.toISOString?.() ?? "",
item.id,
]);
remaining -= 1;
if (remaining <= 0) break;
}
const last = rows[rows.length - 1];
cursorCreatedAt = last.created_at;
cursorId = last.id;
}
}
function exportFilename(prefix, format) {
const now = new Date();
const stamp = now.toISOString().slice(0, 10);
@@ -731,7 +860,48 @@ async function exportsRoutes(fastify) {
return reply.send(stream);
},
);
fastify.get(
"/exports/dashboard/reimbursements",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(DASHBOARD_EXPORT_QUERY_SCHEMA, request.query);
const where = buildDashboardWhere(request, query);
const format = resolveExportFormat(query);
const limit = query.limit ?? 5000;
const total = await fastify.prisma.reimbursementForm.count({ where });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_DASHBOARD",
targetType: "export",
extra: {
format,
limit,
matchedTotal: total,
dateFrom: query.dateFrom ?? null,
dateTo: query.dateTo ?? null,
departmentId: query.departmentId ?? null,
},
}),
});
const filename = exportFilename("dashboard-reimbursements", format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildReimbursementsXlsx(fastify.prisma, where, limit);
return reply.send(Buffer.from(buffer));
}
const stream = Readable.from(streamDashboardReimbursementsCsv(fastify.prisma, where, limit));
return reply.send(stream);
},
);
}
module.exports = exportsRoutes;
+106
查看文件
@@ -0,0 +1,106 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
async function integrationsRoutes(fastify) {
fastify.post(
"/integrations/erp/sync/:reimbursementId",
{
preHandler: fastify.requirePermission("integration.manage"),
},
async (request, reply) => {
if (!fastify.config.erpSyncEnabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { integration: "erp_sync_disabled" },
});
}
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
if (!parsedId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { reimbursementId: "invalid_uuid" },
});
}
const reimbursementId = parsedId.data;
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
owner_id: true,
submitter_id: true,
status: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
paid_at: true,
},
});
if (!reimbursement) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (reimbursement.status !== "paid") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "erp_sync_requires_paid" },
});
}
const payload = buildErpPayload({ reimbursement });
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
eventType: "erp.reimbursement.paid",
payload,
dedupeKey: reimbursement.id,
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
actorId: request.employee.id,
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ERP_SYNC_ENQUEUED",
targetType: "reimbursement_form",
targetId: reimbursementId,
extra: {
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
},
}),
});
const runNow = request.query?.runNow === "1" || request.query?.runNow === 1 || request.query?.runNow === true || request.query?.runNow === "true";
if (runNow) {
await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log);
}
return sendData(reply, request, {
outboxId: enqueued.id,
status: enqueued.status,
duplicated: enqueued.duplicated,
});
},
);
}
module.exports = integrationsRoutes;
@@ -0,0 +1,20 @@
const { JOB_INVOICE_VERIFY } = require("../../../jobs/pgBossJobs");
const { runVerificationForRecord } = require("../../../jobs/verificationJob");
async function triggerVerificationJob(fastify, recordId) {
if (fastify.config.jobDriver === "pgboss" && fastify.boss) {
await fastify.boss.send(JOB_INVOICE_VERIFY, { recordId });
return { mode: "queued" };
}
const result = await runVerificationForRecord(
fastify.prisma,
fastify.config,
recordId,
);
return { mode: "inline", result };
}
module.exports = {
triggerVerificationJob,
};
@@ -0,0 +1,116 @@
const { toMoneyString } = require("../../../utils/workflow");
function mapVerificationRecord(item) {
if (!item) {
return null;
}
return {
id: item.id,
provider: item.provider,
status: item.status,
resultCode: item.result_code ?? null,
resultMessage: item.result_message ?? null,
errorCode: item.error_code ?? null,
errorMessage: item.error_message ?? null,
durationMs: item.duration_ms ?? null,
requestedBy: item.requested_by ?? null,
requestedAt: item.requested_at?.toISOString?.() ?? null,
completedAt: item.completed_at?.toISOString?.() ?? null,
retryOfId: item.retry_of_id ?? null,
attempts: item.attempts ?? 1,
};
}
function mapInvoiceSummary(item) {
const latestVerification = Array.isArray(item.verification_records)
? item.verification_records[0] ?? null
: null;
return {
id: item.id,
status: item.status,
ocrType: item.ocr_type,
ocrProvider: item.ocr_provider,
ocrAttempts: item.ocr_attempts,
ocrLastError: item.ocr_last_error,
ocrCompletedAt: item.ocr_completed_at?.toISOString?.() ?? null,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
amount: toMoneyString(item.amount),
taxAmount: toMoneyString(item.tax_amount),
totalAmount: toMoneyString(item.total_amount),
linkedReimbursementId: item.linked_reimbursement_id,
linkedItemId: item.linked_item_id,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
verification: mapVerificationRecord(latestVerification),
owner: item.owner_employee
? {
id: item.owner_employee.id,
employeeNo: item.owner_employee.employee_no,
name: item.owner_employee.name,
}
: null,
uploadedBy: item.uploaded_by_employee
? {
id: item.uploaded_by_employee.id,
employeeNo: item.uploaded_by_employee.employee_no,
name: item.uploaded_by_employee.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function normalizeInvoiceResponse(invoice) {
const latestVerification = Array.isArray(invoice.verification_records)
? invoice.verification_records[0] ?? null
: null;
return {
id: invoice.id,
status: invoice.status,
archivedReason: invoice.archived_reason ?? null,
archivedAt: invoice.archived_at?.toISOString?.() ?? null,
ownerEmployeeId: invoice.owner_employee_id,
uploadedByEmployeeId: invoice.uploaded_by_employee_id,
originalName: invoice.original_name ?? null,
mimeType: invoice.mime_type ?? null,
fileSize: invoice.file_size ?? null,
fileSha256: invoice.file_sha256 ?? null,
remarks: invoice.remarks ?? null,
ocrType: invoice.ocr_type,
ocrProvider: invoice.ocr_provider,
ocrAttempts: invoice.ocr_attempts,
ocrLastRequestedAt: invoice.ocr_last_requested_at?.toISOString?.() ?? null,
ocrLastError: invoice.ocr_last_error,
ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null,
invoiceCode: invoice.invoice_code,
invoiceNumber: invoice.invoice_number,
invoiceDate: invoice.invoice_date ? invoice.invoice_date.toISOString().slice(0, 10) : null,
sellerName: invoice.seller_name ?? null,
buyerName: invoice.buyer_name ?? null,
amount: toMoneyString(invoice.amount),
taxAmount: toMoneyString(invoice.tax_amount),
totalAmount: toMoneyString(invoice.total_amount),
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
verification: mapVerificationRecord(latestVerification),
isDuplicateOverride: invoice.is_duplicate_override ?? false,
overrideRefFormNumber: invoice.override_ref_form_number ?? null,
overrideReason: invoice.override_reason ?? null,
overriddenBy: invoice.overridden_by ?? null,
createdAt: invoice.created_at?.toISOString?.() ?? null,
updatedAt: invoice.updated_at?.toISOString?.() ?? null,
};
}
module.exports = {
mapVerificationRecord,
mapInvoiceSummary,
normalizeInvoiceResponse,
};
@@ -0,0 +1,27 @@
const { z } = require("zod");
const { AppError } = require("../../../utils/AppError");
const verificationListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(50).optional(),
});
function parseVerificationListQuery(query) {
const parsed = verificationListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
limit: parsed.data.limit ?? 20,
};
}
module.exports = {
parseVerificationListQuery,
};
@@ -0,0 +1,114 @@
function buildVerificationRecordSelect() {
return {
id: true,
provider: true,
status: true,
result_code: true,
result_message: true,
error_code: true,
error_message: true,
duration_ms: true,
requested_by: true,
requested_at: true,
completed_at: true,
retry_of_id: true,
attempts: true,
};
}
function buildLatestVerificationSelect() {
return {
orderBy: { created_at: "desc" },
take: 1,
select: buildVerificationRecordSelect(),
};
}
function buildInvoiceSummarySelect() {
return {
id: true,
status: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
original_name: true,
mime_type: true,
file_size: true,
created_at: true,
updated_at: true,
verification_records: buildLatestVerificationSelect(),
owner_employee: {
select: { id: true, employee_no: true, name: true },
},
uploaded_by_employee: {
select: { id: true, employee_no: true, name: true },
},
};
}
function buildInvoiceDetailSelect(options = {}) {
const select = {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
verification_records: buildLatestVerificationSelect(),
};
if (options.includeLinkedReimbursement) {
select.linked_reimbursement = {
select: {
owner_id: true,
submitter_id: true,
},
};
}
return select;
}
module.exports = {
buildInvoiceSummarySelect,
buildInvoiceDetailSelect,
buildVerificationRecordSelect,
buildLatestVerificationSelect,
};
@@ -0,0 +1,384 @@
const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply");
const { buildAuditLogData } = require("../../utils/audit");
const { getProvider, isVerificationEnabled } = require("../../services/verification");
const { mapVerificationRecord } = require("./shared/mappers");
const { parseVerificationListQuery } = require("./shared/schemas");
const { triggerVerificationJob } = require("./shared/helpers");
function registerInvoiceVerificationRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/verify",
{
preHandler: fastify.requirePermission("invoice.verify"),
},
async (request, reply) => {
if (!isVerificationEnabled(fastify.config)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled" },
});
}
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 invoiceId = parsedInvoiceId.data;
const provider = getProvider(fastify.config);
const now = Date.now();
const response = await fastify.prisma.$transaction(async (tx) => {
const lockedRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(lockedRows) || lockedRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
remarks: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
select: {
id: true,
status: true,
requested_at: true,
attempts: true,
},
});
if (latest?.status === "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
}
if (latest?.requested_at) {
const diffMs = now - new Date(latest.requested_at).getTime();
const cooldownMs =
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
}
}
const created = await tx.invoiceVerificationRecord.create({
data: {
invoice_id: invoiceId,
provider,
status: "pending",
request_payload: {
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
totalAmount: invoice.total_amount?.toString?.() ?? null,
},
requested_by: request.employee.id,
requested_at: new Date(now),
attempts: 1,
},
select: {
id: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_REQUESTED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: created.id,
provider,
},
}),
});
return {
verificationId: created.id,
provider,
};
});
const trigger = await triggerVerificationJob(fastify, response.verificationId);
if (trigger.mode === "inline") {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_INLINE_EXECUTED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: response.verificationId,
status: trigger.result?.status ?? null,
},
}),
});
}
return sendData(reply, request, {
invoiceId,
verificationId: response.verificationId,
provider: response.provider,
triggerMode: trigger.mode,
});
},
);
fastify.get(
"/invoice-pool/:id/verification",
{
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 query = parseVerificationListQuery(request.query);
const invoiceId = parsedInvoiceId.data;
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
const canVerify = fastify.hasPermission(request.employee, "invoice.verify");
if (!canManage && !canVerify) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
linked_reimbursement: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const readable =
canManage ||
canVerify ||
invoice.owner_employee_id === request.employee.id ||
invoice.uploaded_by_employee_id === request.employee.id ||
invoice.linked_reimbursement?.owner_id === request.employee.id ||
invoice.linked_reimbursement?.submitter_id === request.employee.id;
if (!readable) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const items = await fastify.prisma.invoiceVerificationRecord.findMany({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
take: query.limit,
});
return sendData(reply, request, {
invoiceId,
items: items.map(mapVerificationRecord),
});
},
);
fastify.post(
"/invoice-pool/:id/verification/retry",
{
preHandler: fastify.requirePermission("invoice.verify"),
},
async (request, reply) => {
if (!isVerificationEnabled(fastify.config)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled" },
});
}
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 invoiceId = parsedInvoiceId.data;
const provider = getProvider(fastify.config);
const created = await fastify.prisma.$transaction(async (tx) => {
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
select: {
id: true,
status: true,
attempts: true,
requested_at: true,
},
});
if (!latest) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "not_found" },
});
}
if (latest.status === "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
}
const cooldownMs =
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
const diffMs = Date.now() - new Date(latest.requested_at).getTime();
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
}
const attempts = Number(latest.attempts ?? 1) + 1;
if (attempts > Number(fastify.config.verificationMaxAttempts ?? 3)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "attempts_exceeded" },
});
}
const createdRecord = await tx.invoiceVerificationRecord.create({
data: {
invoice_id: invoiceId,
provider,
status: "pending",
retry_of_id: latest.id,
attempts,
requested_by: request.employee.id,
requested_at: new Date(),
request_payload: {
retryOfId: latest.id,
},
},
select: {
id: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_RETRIED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: createdRecord.id,
retryOfId: latest.id,
attempts,
},
}),
});
return createdRecord;
});
await triggerVerificationJob(fastify, created.id);
return sendData(reply, request, {
invoiceId,
verificationId: created.id,
status: "pending",
});
},
);
}
module.exports = {
registerInvoiceVerificationRoutes,
};
+114 -1
查看文件
@@ -2,6 +2,8 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { sendInstantMessageByChannel } = require("../services/notifications/channels");
const LIST_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
@@ -58,6 +60,19 @@ function mapNotificationItem(record) {
};
}
function mapNotificationChannel(record) {
return {
id: record.id,
channelType: record.channel_type,
name: record.name,
enabled: record.enabled,
configJson: record.config_json,
templateJson: record.template_json,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
async function notificationsRoutes(fastify) {
fastify.get(
"/notifications",
@@ -189,7 +204,105 @@ async function notificationsRoutes(fastify) {
return sendData(reply, request, mapNotificationItem(notification));
},
);
fastify.get(
"/notifications/channels",
{
preHandler: fastify.requirePermission("notification.channel.manage"),
},
async (request, reply) => {
const items = await fastify.prisma.notificationChannel.findMany({
orderBy: [{ channel_type: "asc" }, { name: "asc" }],
});
return sendData(reply, request, {
items: items.map(mapNotificationChannel),
});
},
);
fastify.post(
"/notifications/channels/test",
{
preHandler: fastify.requirePermission("notification.channel.manage"),
},
async (request, reply) => {
const schema = z
.object({
channelType: z.string().trim().min(1).max(32),
name: z.string().trim().min(1).max(100),
})
.strict();
const parsed = schema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const input = parsed.data;
const channel = await fastify.prisma.notificationChannel.upsert({
where: {
channel_type_name: {
channel_type: input.channelType,
name: input.name,
},
},
update: {
enabled: true,
config_json: {
mode: "mock",
},
updated_by: request.employee.id,
},
create: {
channel_type: input.channelType,
name: input.name,
enabled: true,
config_json: {
mode: "mock",
},
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
const result = await sendInstantMessageByChannel({
config: fastify.config,
channel,
payload: {
title: "通知渠道测试",
body: `测试人:${request.employee.id}`,
targetType: "notification_channel",
targetId: channel.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "NOTIFICATION_CHANNEL_TESTED",
targetType: "notification_channel",
targetId: channel.id,
extra: {
channelType: channel.channel_type,
name: channel.name,
resultCode: result.code,
},
}),
});
return sendData(reply, request, {
channel: mapNotificationChannel(channel),
testResult: result,
});
},
);
}
module.exports = notificationsRoutes;
+144
查看文件
@@ -9,6 +9,8 @@ const {
resolveRecipientsForAssignee,
} = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
@@ -346,6 +348,50 @@ async function loadApprovalRuleForAmount(tx, totalAmount) {
return approvalRule;
}
async function resolveApplicableBudgetPool(tx, reimbursement) {
return tx.budgetPool.findFirst({
where: {
status: "active",
OR: [{ department_id: reimbursement.department_id }, { department_id: null }],
period_start: { lte: reimbursement.reimbursement_date },
period_end: { gte: reimbursement.reimbursement_date },
},
orderBy: [{ department_id: "desc" }, { created_at: "asc" }],
});
}
function normalizeBudgetControlMode(config) {
const mode = String(config.budgetControlMode ?? "warn").toLowerCase();
if (mode === "block") {
return "block";
}
return "warn";
}
function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
const usedAmount = Number(budgetPool.used_amount ?? 0);
const currentAmount = Number(reimbursementAmount ?? 0);
const nextUsed = usedAmount + currentAmount;
const nextUsagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
return {
budgetId: budgetPool.id,
budgetCode: budgetPool.code,
budgetName: budgetPool.name,
controlMode,
warningThresholdPercent: budgetPool.warning_threshold_percent,
allocatedAmount: allocatedAmount.toFixed(2),
usedAmount: usedAmount.toFixed(2),
remainingAmount: Math.max(0, allocatedAmount - usedAmount).toFixed(2),
currentAmount: currentAmount.toFixed(2),
nextUsedAmount: nextUsed.toFixed(2),
nextUsagePercent: Number(nextUsagePercent.toFixed(2)),
exceedsThreshold: nextUsagePercent >= budgetPool.warning_threshold_percent,
exceedsBudget: nextUsed > allocatedAmount,
};
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
@@ -638,6 +684,12 @@ async function reimbursementRoutes(fastify) {
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const controlMode = normalizeBudgetControlMode(fastify.config);
const budgetPool = await resolveApplicableBudgetPool(fastify.prisma, reimbursement);
const budgetHint = budgetPool
? buildBudgetHint(budgetPool, reimbursement.total_amount, controlMode)
: null;
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
@@ -669,6 +721,7 @@ async function reimbursementRoutes(fastify) {
}
: null,
})),
budgetHint,
invoices: reimbursement.invoice_pool_items.map((item) => ({
id: item.id,
status: item.status,
@@ -1142,6 +1195,7 @@ async function reimbursementRoutes(fastify) {
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let submitResult = null;
let budgetCheck = null;
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
@@ -1206,6 +1260,71 @@ async function reimbursementRoutes(fastify) {
});
}
const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement);
if (budgetPool) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
const usedAmount = Number(budgetPool.used_amount ?? 0);
const currentAmount = Number(reimbursement.total_amount ?? 0);
const nextUsed = usedAmount + currentAmount;
const usagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
const controlMode = normalizeBudgetControlMode(fastify.config);
budgetCheck = {
budgetId: budgetPool.id,
budgetCode: budgetPool.code,
controlMode,
usagePercent: Number(usagePercent.toFixed(2)),
exceededThreshold: usagePercent >= budgetPool.warning_threshold_percent,
exceededBudget: nextUsed > allocatedAmount,
};
if (usagePercent >= budgetPool.warning_threshold_percent) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_THRESHOLD_HIT",
targetType: "budget_pool",
targetId: budgetPool.id,
extra: {
reimbursementId,
formNumber: reimbursement.form_number ?? null,
usagePercent: Number(usagePercent.toFixed(2)),
threshold: budgetPool.warning_threshold_percent,
controlMode,
},
}),
});
}
if (controlMode === "block" && nextUsed > allocatedAmount) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { budget: "exceeded_block_mode" },
});
}
await tx.budgetLedgerEntry.create({
data: {
budget_pool_id: budgetPool.id,
reimbursement_id: reimbursementId,
kind: controlMode === "block" && nextUsed > allocatedAmount ? "blocked" : "submit",
amount: reimbursement.total_amount,
note: `submit:${reimbursement.form_number ?? reimbursementId}`,
created_by: request.employee.id,
},
});
await tx.budgetPool.update({
where: { id: budgetPool.id },
data: {
used_amount: nextUsed.toFixed(2),
updated_by: request.employee.id,
},
});
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: {
entity_type: "reimbursement",
@@ -1405,6 +1524,7 @@ async function reimbursementRoutes(fastify) {
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
status: "reviewing",
budget: budgetCheck,
};
});
@@ -1645,6 +1765,30 @@ async function reimbursementRoutes(fastify) {
},
});
const refreshed = await tx.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
owner_id: true,
submitter_id: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
paid_at: true,
},
});
if (refreshed && fastify.config.erpSyncEnabled) {
await enqueueIntegrationEvent(tx, {
eventType: "erp.reimbursement.paid",
dedupeKey: refreshed.id,
payload: buildErpPayload({ reimbursement: refreshed }),
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
actorId: request.employee.id,
});
}
try {
const recipients = Array.from(
new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)),
+152
查看文件
@@ -0,0 +1,152 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { normalizeTravelImportRows } = require("../services/integrations/travel");
const IMPORT_SCHEMA = z.object({
orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
});
function mapTravelOrder(item) {
return {
id: item.id,
source: item.source,
externalOrderNo: item.external_order_no,
employeeId: item.employee_id,
departmentId: item.department_id ?? null,
linkedReimbursementId: item.linked_reimbursement_id ?? null,
occurredAt: item.occurred_at?.toISOString?.()?.slice(0, 10) ?? null,
amount: item.amount?.toString?.() ?? null,
currency: item.currency,
status: item.status,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
async function travelOrdersRoutes(fastify) {
fastify.post(
"/travel/orders/import",
{
preHandler: fastify.requirePermission("integration.manage"),
},
async (request, reply) => {
if (!fastify.config.travelSyncEnabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { integration: "travel_sync_disabled" },
});
}
const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const rows = normalizeTravelImportRows(parsed.data.orders);
if (!rows.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { orders: "empty_after_normalization" },
});
}
const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId)));
const employees = await fastify.prisma.employee.findMany({
where: {
id: { in: employeeIds },
status: "active",
},
select: {
id: true,
department_id: true,
},
});
const employeeMap = new Map(employees.map((item) => [item.id, item]));
const created = [];
const skipped = [];
for (const row of rows) {
const employee = employeeMap.get(row.employeeId);
if (!employee) {
skipped.push({
source: row.source,
externalOrderNo: row.externalOrderNo,
reason: "employee_not_found",
});
continue;
}
const upserted = await fastify.prisma.travelOrder.upsert({
where: {
source_external_order_no: {
source: row.source,
external_order_no: row.externalOrderNo,
},
},
update: {
employee_id: row.employeeId,
department_id: employee.department_id,
linked_reimbursement_id: row.linkedReimbursementId,
occurred_at: row.occurredAt,
amount: row.amount,
currency: row.currency,
status: "imported",
raw_payload: row.rawPayload,
updated_by: request.employee.id,
},
create: {
source: row.source,
external_order_no: row.externalOrderNo,
employee_id: row.employeeId,
department_id: employee.department_id,
linked_reimbursement_id: row.linkedReimbursementId,
occurred_at: row.occurredAt,
amount: row.amount,
currency: row.currency,
status: "imported",
raw_payload: row.rawPayload,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
created.push(upserted);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_ORDERS_IMPORTED",
targetType: "travel_order",
extra: {
requested: rows.length,
createdOrUpdated: created.length,
skipped,
},
}),
});
return sendData(reply, request, {
requested: rows.length,
imported: created.length,
skipped,
items: created.map(mapTravelOrder),
});
},
);
}
module.exports = travelOrdersRoutes;