feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)
这个提交包含在:
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户