文件
hyb-oa/server/src/routes/exports.js
T
wangsli 7c39f8b0f9
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
收口付款批次创建与复核(08-01-reimbursement-notification-progress-clarity)
2026-08-02 00:29:47 +08:00

2097 行
63 KiB
JavaScript

const { Readable } = require("node:stream");
const ExcelJS = require("exceljs");
const { z } = require("zod");
const {
CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA,
buildContractRegistryWhere,
formatContractRegistryAmount,
formatContractRegistryDateOnly,
parseContractRegistryFilters,
resolveContractRegistryEffectiveStatus,
resolveContractRegistryToday,
} = require("../services/contractRegistryFilters");
const {
EMPLOYMENT_STATUS_VALUES,
buildEmployeeDirectoryWhere,
parseEmployeeDirectoryFilters,
} = require("../services/employeeDirectoryFilters");
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
FINANCE_LEDGER_EXPORT_HEADERS,
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
REIMBURSEMENT_LEDGER_STATUS_VALUES,
listFinanceLedgerForExport,
mapFinanceLedgerExportRow,
} = require("../services/financeLedger");
const {
HISTORY_STATUS_VALUES,
buildPresenceValidationError,
buildTeamPresenceHistory,
resolvePresenceHistoryRange,
} = require("../services/presenceHistory");
const { loadMonthlyPayslips, parsePayMonth } = require("../services/payroll");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const EXPORT_FORMAT_SCHEMA = z.enum(["csv", "xlsx"]);
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const REIMBURSEMENT_STATUS_SCHEMA = z.enum([
"draft",
"reviewing",
"approved",
"paid",
"rejected",
"cancelled",
]);
const REIMBURSEMENT_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
scope: z.enum(["own", "all"]).optional(),
status: REIMBURSEMENT_STATUS_SCHEMA.optional(),
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
const INVOICE_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
ownerScope: z.enum(["own", "all"]).optional(),
status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(),
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
const AUDIT_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
actorId: z.string().uuid().optional(),
action: z.string().min(1).max(100).optional(),
targetType: z.string().min(1).max(100).optional(),
dateFrom: z.string().datetime().optional(),
dateTo: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(50000).optional(),
});
const DASHBOARD_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
dateFrom: DATE_ONLY_SCHEMA.optional(),
dateTo: DATE_ONLY_SCHEMA.optional(),
departmentId: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
status: z.enum(["active", "archived"]).optional(),
departmentId: z.string().uuid().optional(),
q: z.string().max(100).optional(),
title: z.string().max(100).optional(),
managerId: z.string().uuid().optional(),
hireDateFrom: DATE_ONLY_SCHEMA.optional(),
hireDateTo: DATE_ONLY_SCHEMA.optional(),
employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(),
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
const PAYSLIP_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
month: z.string().regex(/^\d{4}-\d{2}$/),
});
const PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
dateFrom: DATE_ONLY_SCHEMA.optional(),
dateTo: DATE_ONLY_SCHEMA.optional(),
departmentId: z.string().uuid().optional(),
employeeId: z.string().uuid().optional(),
status: z.enum(HISTORY_STATUS_VALUES).optional(),
});
const CONTRACT_REGISTRY_EXPORT_BODY_SCHEMA = CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA.extend({
format: EXPORT_FORMAT_SCHEMA.optional(),
});
const AP_OPERATIONS_EXPORT_BODY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
pendingEntityType: z.enum(["reimbursement", "travel_order"]).optional(),
pendingCreatedAtFrom: DATE_ONLY_SCHEMA.optional(),
pendingCreatedAtTo: DATE_ONLY_SCHEMA.optional(),
riskBucket: z.enum(["all", "exception", "budget_overspend"]).optional(),
ledgerStatus: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(),
ledgerPaymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(),
ledgerInvoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(),
ledgerDateFrom: DATE_ONLY_SCHEMA.optional(),
ledgerDateTo: DATE_ONLY_SCHEMA.optional(),
ledgerSearch: z.string().trim().max(100).optional(),
}).strict();
const PRESENCE_HISTORY_EXPORT_LIMIT = 5000;
const AP_PENDING_BATCH_STATUSES = ["draft", "reviewed", "failed"];
const AP_OPERATIONS_PERMISSION_CODES = ["payment.batch.manage", "risk.review.manage"];
function csvCell(value) {
if (value == null) return "";
const text = String(value);
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
function csvRow(values) {
return `${values.map(csvCell).join(",")}\n`;
}
function parseQuery(schema, query) {
const parsed = schema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function resolveExportFormat(query) {
return query.format ?? "csv";
}
function ensureHasAnyPermission(fastify, request, permissionCodes) {
for (const code of permissionCodes) {
if (fastify.hasPermission(request.employee, code)) {
return;
}
}
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
function buildOwnReimbursementScopeWhere(employeeId) {
return {
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
};
}
function isGlobalViewRole(roleCodes) {
return roleCodes.some((code) =>
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
);
}
function buildViewAllScopeWhere(request) {
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {};
}
return {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
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);
return `${prefix}-${stamp}.${format}`;
}
function setExportHeaders(reply, format, filename) {
if (format === "csv") {
reply.header("content-type", "text/csv; charset=utf-8");
} else {
reply.header(
"content-type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
}
reply.header("content-disposition", `attachment; filename=\"${filename}\"`);
}
async function ensureApOperationsExportEnabled(fastify) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
strictDepartment: false,
});
if (!gate.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { featureFlag: "payment_batch_v1_disabled", reason: gate.reason },
});
}
}
async function ensureContractsRegistryExportEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.projectLinking,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason },
});
}
}
function confidentialityLevelLabel(value) {
if (value === "public") return "公开";
if (value === "internal") return "内部";
if (value === "confidential") return "机密";
return "限制";
}
function contractRegistryTypeLabel(value) {
if (value === "purchase") return "采购合同";
if (value === "sales") return "销售合同";
if (value === "service") return "服务合同";
if (value === "framework") return "框架协议";
return "其他";
}
function contractRegistryStatusLabel(value) {
if (value === "draft") return "草稿";
if (value === "active") return "生效中";
if (value === "expired") return "已过期";
if (value === "archived") return "已归档";
return value ?? "";
}
function paymentBatchEntityTypeLabel(value) {
if (value === "reimbursement") return "报销";
if (value === "travel_order") return "差旅";
return value ?? "";
}
function paymentBatchStatusLabel(value) {
if (value === "draft") return "待付款复核";
if (value === "reviewed") return "待财务付款";
if (value === "executed") return "已付款";
if (value === "failed") return "付款登记失败";
if (value === "cancelled") return "已取消";
return value ?? "";
}
function riskBucketLabel(value) {
if (value === "budget_overspend") return "预算超支";
return "异常风险";
}
function riskFlagStatusLabel(value) {
if (value === "open") return "待处理";
if (value === "resolved") return "已解决";
if (value === "dismissed") return "已驳回";
return value ?? "";
}
function riskLevelLabel(value) {
if (value === "high") return "高";
if (value === "medium") return "中";
if (value === "low") return "低";
return value ?? "";
}
function reconciliationMatchStatusLabel(value) {
if (value === "amount_mismatch") return "金额不匹配";
if (value === "currency_mismatch") return "币种不匹配";
if (value === "missing_transaction") return "缺少交易";
if (value === "duplicate_charge") return "重复扣款";
if (value === "orphan_transaction") return "孤儿交易";
if (value === "reversed_after_payment") return "付款后冲正";
if (value === "matched") return "已匹配";
return value ?? "";
}
function reconciliationResolutionStatusLabel(value) {
if (value === "pending") return "待确认";
if (value === "confirmed") return "已确认";
return value ?? "";
}
function resolveApOperationsRiskBucket(riskCode) {
return BUDGET_RISK_CODES.includes(riskCode) ? "budget_overspend" : "exception";
}
function toDateOnlyRange(dateFrom, dateTo, fieldNames) {
const from = dateFrom ? new Date(`${dateFrom}T00:00:00.000Z`) : null;
const to = dateTo ? new Date(`${dateTo}T23:59:59.999Z`) : null;
if (from && to && from > to) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { [fieldNames.to]: "must_be_after_or_equal_to_from" },
});
}
return { from, to };
}
function buildApOperationsPendingBatchWhere(filters) {
const { from, to } = toDateOnlyRange(
filters.pendingCreatedAtFrom,
filters.pendingCreatedAtTo,
{ from: "pendingCreatedAtFrom", to: "pendingCreatedAtTo" },
);
return {
status: { in: AP_PENDING_BATCH_STATUSES },
...(filters.pendingEntityType ? { entity_type: filters.pendingEntityType } : {}),
...(from || to
? {
created_at: {
...(from ? { gte: from } : {}),
...(to ? { lte: to } : {}),
},
}
: {}),
};
}
function buildApOperationsRiskWhere(filters) {
if (filters.riskBucket === "budget_overspend") {
return {
status: "open",
risk_code: { in: BUDGET_RISK_CODES },
};
}
if (filters.riskBucket === "exception") {
return {
status: "open",
risk_code: { notIn: BUDGET_RISK_CODES },
};
}
return { status: "open" };
}
function mapApPendingBatchExportRow(item) {
const totalAmount = toMoneyString((item.items ?? []).reduce((sum, entry) => sum + toMoneyNumber(entry.amount), 0));
return [
item.batch_no,
paymentBatchEntityTypeLabel(item.entity_type),
item.name,
paymentBatchStatusLabel(item.status),
totalAmount,
toMoneyNumber(totalAmount) >= 50000 ? "是" : "否",
item.created_at?.toISOString?.() ?? "",
];
}
function mapApFailedPaymentItemExportRow(item) {
return [
item.payment_batch?.batch_no ?? item.payment_batch_id,
paymentBatchEntityTypeLabel(item.payment_batch?.entity_type),
item.entity_code,
item.entity_title,
toMoneyString(item.amount),
item.currency_code,
item.failure_code ?? "",
item.failure_message ?? "",
item.created_at?.toISOString?.() ?? "",
];
}
function mapApRiskFlagExportRow(item) {
return [
riskBucketLabel(resolveApOperationsRiskBucket(item.risk_code)),
item.title,
item.risk_code,
riskLevelLabel(item.level),
item.entity_type,
item.entity_id,
item.source,
riskFlagStatusLabel(item.status),
item.detail ?? "",
item.created_at?.toISOString?.() ?? "",
];
}
function mapApReconciliationAnomalyExportRow(item) {
return [
item.batch?.batch_no ?? item.travel_reconciliation_batch_id,
item.anomaly_code ?? "",
reconciliationMatchStatusLabel(item.match_status),
reconciliationResolutionStatusLabel(item.resolution_status),
item.travel_order_id ?? "",
item.travel_request_id ?? "",
item.order_amount != null ? toMoneyString(item.order_amount) : "",
item.transaction_amount != null ? toMoneyString(item.transaction_amount) : "",
item.currency ?? "",
item.created_at?.toISOString?.() ?? "",
];
}
async function loadApOperationsExportDataset(prisma, filters) {
const pendingWhere = buildApOperationsPendingBatchWhere(filters);
const riskWhere = buildApOperationsRiskWhere(filters);
const reconciliationWhere = {
resolution_status: "pending",
match_status: { not: "matched" },
};
const ledgerFilters = {
status: filters.ledgerStatus,
paymentStatus: filters.ledgerPaymentStatus,
invoiceStatus: filters.ledgerInvoiceStatus,
dateFrom: filters.ledgerDateFrom,
dateTo: filters.ledgerDateTo,
q: filters.ledgerSearch,
};
const [
reimbursementLedger,
pendingPaymentBatches,
failedPaymentBatchItems,
riskFlags,
reconciliationAnomalies,
] = await Promise.all([
listFinanceLedgerForExport(prisma, ledgerFilters, 5000),
prisma.paymentBatch.findMany({
where: pendingWhere,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: {
items: {
select: {
amount: true,
},
},
},
}),
prisma.paymentBatchItem.findMany({
where: { status: "failed" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: {
payment_batch: {
select: {
batch_no: true,
entity_type: true,
},
},
},
}),
prisma.entityRiskFlag.findMany({
where: riskWhere,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
}),
prisma.travelReconciliationItem.findMany({
where: reconciliationWhere,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
include: {
batch: {
select: {
batch_no: true,
},
},
},
}),
]);
return {
reimbursementLedger,
pendingWhere,
riskWhere,
reconciliationWhere,
pendingPaymentBatches,
failedPaymentBatchItems,
riskFlags,
reconciliationAnomalies,
};
}
async function* streamApOperationsCsv(dataset) {
yield "\ufeff";
yield csvRow(["财务台账"]);
yield csvRow(FINANCE_LEDGER_EXPORT_HEADERS);
for (const item of dataset.reimbursementLedger) {
yield csvRow(mapFinanceLedgerExportRow(item));
}
yield csvRow([]);
yield csvRow(["待付款批次"]);
yield csvRow(["批次编号", "实体类型", "批次名称", "状态", "总金额", "高风险", "创建时间"]);
for (const item of dataset.pendingPaymentBatches) {
yield csvRow(mapApPendingBatchExportRow(item));
}
yield csvRow([]);
yield csvRow(["异常付款项"]);
yield csvRow(["批次编号", "实体类型", "单据编号", "单据标题", "金额", "币种", "失败码", "失败原因", "创建时间"]);
for (const item of dataset.failedPaymentBatchItems) {
yield csvRow(mapApFailedPaymentItemExportRow(item));
}
yield csvRow([]);
yield csvRow(["风险标记"]);
yield csvRow(["风险分组", "标题", "风险编码", "等级", "实体类型", "实体 ID", "来源", "状态", "明细", "创建时间"]);
for (const item of dataset.riskFlags) {
yield csvRow(mapApRiskFlagExportRow(item));
}
yield csvRow([]);
yield csvRow(["对账异常"]);
yield csvRow(["批次编号", "异常编码", "匹配状态", "确认状态", "差旅订单 ID", "差旅申请 ID", "订单金额", "交易金额", "币种", "创建时间"]);
for (const item of dataset.reconciliationAnomalies) {
yield csvRow(mapApReconciliationAnomalyExportRow(item));
}
}
async function buildApOperationsXlsx(dataset) {
const workbook = new ExcelJS.Workbook();
const ledgerSheet = workbook.addWorksheet("财务台账");
ledgerSheet.columns = FINANCE_LEDGER_EXPORT_HEADERS.map((header, index) => ({
header,
key: `col${index}`,
width: index === 0 ? 24 : index === 14 ? 24 : 16,
}));
for (const item of dataset.reimbursementLedger) {
const row = mapFinanceLedgerExportRow(item);
ledgerSheet.addRow(Object.fromEntries(row.map((value, index) => [`col${index}`, value])));
}
const pendingSheet = workbook.addWorksheet("待付款批次");
pendingSheet.columns = [
{ header: "批次编号", key: "batchNo", width: 24 },
{ header: "实体类型", key: "entityType", width: 14 },
{ header: "批次名称", key: "name", width: 30 },
{ header: "状态", key: "status", width: 14 },
{ header: "总金额", key: "totalAmount", width: 14 },
{ header: "高风险", key: "highRisk", width: 10 },
{ header: "创建时间", key: "createdAt", width: 24 },
];
for (const item of dataset.pendingPaymentBatches) {
const row = mapApPendingBatchExportRow(item);
pendingSheet.addRow({
batchNo: row[0],
entityType: row[1],
name: row[2],
status: row[3],
totalAmount: row[4],
highRisk: row[5],
createdAt: row[6],
});
}
const failedSheet = workbook.addWorksheet("异常付款项");
failedSheet.columns = [
{ header: "批次编号", key: "batchNo", width: 24 },
{ header: "实体类型", key: "entityType", width: 14 },
{ header: "单据编号", key: "entityCode", width: 24 },
{ header: "单据标题", key: "entityTitle", width: 28 },
{ header: "金额", key: "amount", width: 14 },
{ header: "币种", key: "currencyCode", width: 10 },
{ header: "失败码", key: "failureCode", width: 20 },
{ header: "失败原因", key: "failureMessage", width: 32 },
{ header: "创建时间", key: "createdAt", width: 24 },
];
for (const item of dataset.failedPaymentBatchItems) {
const row = mapApFailedPaymentItemExportRow(item);
failedSheet.addRow({
batchNo: row[0],
entityType: row[1],
entityCode: row[2],
entityTitle: row[3],
amount: row[4],
currencyCode: row[5],
failureCode: row[6],
failureMessage: row[7],
createdAt: row[8],
});
}
const riskSheet = workbook.addWorksheet("风险标记");
riskSheet.columns = [
{ header: "风险分组", key: "bucket", width: 14 },
{ header: "标题", key: "title", width: 28 },
{ header: "风险编码", key: "riskCode", width: 24 },
{ header: "等级", key: "level", width: 10 },
{ header: "实体类型", key: "entityType", width: 16 },
{ header: "实体 ID", key: "entityId", width: 38 },
{ header: "来源", key: "source", width: 14 },
{ header: "状态", key: "status", width: 12 },
{ header: "明细", key: "detail", width: 40 },
{ header: "创建时间", key: "createdAt", width: 24 },
];
for (const item of dataset.riskFlags) {
const row = mapApRiskFlagExportRow(item);
riskSheet.addRow({
bucket: row[0],
title: row[1],
riskCode: row[2],
level: row[3],
entityType: row[4],
entityId: row[5],
source: row[6],
status: row[7],
detail: row[8],
createdAt: row[9],
});
}
const reconciliationSheet = workbook.addWorksheet("对账异常");
reconciliationSheet.columns = [
{ header: "批次编号", key: "batchNo", width: 24 },
{ header: "异常编码", key: "anomalyCode", width: 20 },
{ header: "匹配状态", key: "matchStatus", width: 16 },
{ header: "确认状态", key: "resolutionStatus", width: 14 },
{ header: "差旅订单 ID", key: "travelOrderId", width: 38 },
{ header: "差旅申请 ID", key: "travelRequestId", width: 38 },
{ header: "订单金额", key: "orderAmount", width: 14 },
{ header: "交易金额", key: "transactionAmount", width: 14 },
{ header: "币种", key: "currency", width: 10 },
{ header: "创建时间", key: "createdAt", width: 24 },
];
for (const item of dataset.reconciliationAnomalies) {
const row = mapApReconciliationAnomalyExportRow(item);
reconciliationSheet.addRow({
batchNo: row[0],
anomalyCode: row[1],
matchStatus: row[2],
resolutionStatus: row[3],
travelOrderId: row[4],
travelRequestId: row[5],
orderAmount: row[6],
transactionAmount: row[7],
currency: row[8],
createdAt: row[9],
});
}
return workbook.xlsx.writeBuffer();
}
function mapContractRegistryExportRow(item, today) {
const effectiveStatus = resolveContractRegistryEffectiveStatus(item, today);
return [
item.contract_no,
contractRegistryTypeLabel(item.contract_type),
item.counterparty_name,
formatContractRegistryAmount(item.amount),
item.currency,
contractRegistryStatusLabel(effectiveStatus),
formatContractRegistryDateOnly(item.signed_date),
formatContractRegistryDateOnly(item.effective_date),
formatContractRegistryDateOnly(item.expiry_date),
item.project ? `${item.project.project_no} · ${item.project.name}` : "",
item.owner?.name ?? "",
confidentialityLevelLabel(item.confidentiality_level),
];
}
async function* streamContractsRegistryCsv(prisma, where, today) {
yield "\ufeff";
yield csvRow([
"合同编号",
"类型",
"相对方",
"金额",
"币种",
"状态",
"签署日期",
"生效日期",
"到期日期",
"项目",
"责任人",
"密级",
]);
const batchSize = 500;
let cursorUpdatedAt = null;
let cursorId = null;
while (true) {
const rows = await prisma.contractRegistry.findMany({
where: {
...where,
...(cursorUpdatedAt
? {
OR: [
{ updated_at: { lt: cursorUpdatedAt } },
{ updated_at: cursorUpdatedAt, id: { lt: cursorId } },
],
}
: {}),
},
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
take: batchSize,
include: {
owner: { select: { name: true } },
project: { select: { project_no: true, name: true } },
},
});
if (!rows.length) {
break;
}
for (const item of rows) {
yield csvRow(mapContractRegistryExportRow(item, today));
}
const last = rows[rows.length - 1];
cursorUpdatedAt = last.updated_at;
cursorId = last.id;
}
}
async function buildContractsRegistryXlsx(prisma, where, today) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("contracts_registry");
sheet.columns = [
{ header: "合同编号", key: "contractNo", width: 22 },
{ header: "类型", key: "contractType", width: 16 },
{ header: "相对方", key: "counterpartyName", width: 24 },
{ header: "金额", key: "amount", width: 16 },
{ header: "币种", key: "currency", width: 12 },
{ header: "状态", key: "status", width: 12 },
{ header: "签署日期", key: "signedDate", width: 14 },
{ header: "生效日期", key: "effectiveDate", width: 14 },
{ header: "到期日期", key: "expiryDate", width: 14 },
{ header: "项目", key: "projectName", width: 24 },
{ header: "责任人", key: "ownerName", width: 16 },
{ header: "密级", key: "confidentialityLevel", width: 12 },
];
const rows = await prisma.contractRegistry.findMany({
where,
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
include: {
owner: { select: { name: true } },
project: { select: { project_no: true, name: true } },
},
});
for (const item of rows) {
sheet.addRow({
contractNo: item.contract_no,
contractType: contractRegistryTypeLabel(item.contract_type),
counterpartyName: item.counterparty_name,
amount: formatContractRegistryAmount(item.amount),
currency: item.currency,
status: contractRegistryStatusLabel(resolveContractRegistryEffectiveStatus(item, today)),
signedDate: formatContractRegistryDateOnly(item.signed_date),
effectiveDate: formatContractRegistryDateOnly(item.effective_date),
expiryDate: formatContractRegistryDateOnly(item.expiry_date),
projectName: item.project ? `${item.project.project_no} · ${item.project.name}` : "",
ownerName: item.owner?.name ?? "",
confidentialityLevel: confidentialityLevelLabel(item.confidentiality_level),
});
}
return workbook.xlsx.writeBuffer();
}
async function* streamReimbursementsCsv(prisma, where, limit) {
yield "\ufeff";
yield csvRow([
"formNumber",
"status",
"totalAmount",
"ownerName",
"submitterName",
"reimbursementDate",
"paymentVoucherNo",
"paymentDate",
"paymentChannel",
"paidAt",
"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,
total_amount: true,
reimbursement_date: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: 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.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
item.owner?.name ?? "",
item.submitter?.name ?? "",
item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? "",
item.payment_voucher_no ?? "",
item.payment_date?.toISOString?.()?.slice(0, 10) ?? "",
item.payment_channel ?? "",
item.paid_at?.toISOString?.() ?? "",
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;
}
}
async function buildReimbursementsXlsx(prisma, where, limit) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("reimbursements");
sheet.columns = [
{ header: "单号", key: "formNumber", width: 18 },
{ header: "状态", key: "status", width: 12 },
{ header: "金额", key: "totalAmount", width: 12 },
{ header: "报销人", key: "ownerName", width: 14 },
{ header: "提交人", key: "submitterName", width: 14 },
{ header: "报销日期", key: "reimbursementDate", width: 14 },
{ header: "付款凭证号", key: "paymentVoucherNo", width: 18 },
{ header: "付款日期", key: "paymentDate", width: 14 },
{ header: "付款渠道", key: "paymentChannel", width: 12 },
{ header: "付款时间", key: "paidAt", width: 20 },
{ header: "创建时间", key: "createdAt", width: 20 },
{ header: "ID", key: "id", width: 36 },
];
const rows = await prisma.reimbursementForm.findMany({
where,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: limit,
select: {
id: true,
form_number: true,
status: true,
total_amount: true,
reimbursement_date: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
created_at: true,
owner: { select: { name: true } },
submitter: { select: { name: true } },
},
});
for (const item of rows) {
sheet.addRow({
formNumber: item.form_number ?? "",
status: item.status,
totalAmount: item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
ownerName: item.owner?.name ?? "",
submitterName: item.submitter?.name ?? "",
reimbursementDate: item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? "",
paymentVoucherNo: item.payment_voucher_no ?? "",
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? "",
paymentChannel: item.payment_channel ?? "",
paidAt: item.paid_at?.toISOString?.() ?? "",
createdAt: item.created_at?.toISOString?.() ?? "",
id: item.id,
});
}
return workbook.xlsx.writeBuffer();
}
async function* streamInvoicesCsv(prisma, where, limit) {
yield "\ufeff";
yield csvRow([
"status",
"ownerName",
"uploadedByName",
"invoiceCode",
"invoiceNumber",
"invoiceDate",
"totalAmount",
"archivedReason",
"linkedReimbursementId",
"ocrLastError",
"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.invoicePool.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,
status: true,
archived_reason: true,
owner_employee: { select: { name: true } },
uploaded_by_employee: { select: { name: true } },
invoice_code: true,
invoice_number: true,
invoice_date: true,
total_amount: true,
linked_reimbursement_id: true,
ocr_last_error: true,
created_at: true,
},
});
if (!rows.length) {
break;
}
for (const item of rows) {
yield csvRow([
item.status,
item.owner_employee?.name ?? "",
item.uploaded_by_employee?.name ?? "",
item.invoice_code ?? "",
item.invoice_number ?? "",
item.invoice_date?.toISOString?.()?.slice(0, 10) ?? "",
item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
item.archived_reason ?? "",
item.linked_reimbursement_id ?? "",
item.ocr_last_error ?? "",
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;
}
}
async function buildInvoicesXlsx(prisma, where, limit) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("invoice_pool");
sheet.columns = [
{ header: "状态", key: "status", width: 12 },
{ header: "归属人", key: "ownerName", width: 14 },
{ header: "上传人", key: "uploadedByName", width: 14 },
{ header: "发票代码", key: "invoiceCode", width: 14 },
{ header: "发票号码", key: "invoiceNumber", width: 14 },
{ header: "开票日期", key: "invoiceDate", width: 14 },
{ header: "总金额", key: "totalAmount", width: 12 },
{ header: "归档原因", key: "archivedReason", width: 14 },
{ header: "关联报销单", key: "linkedReimbursementId", width: 36 },
{ header: "OCR错误", key: "ocrLastError", width: 18 },
{ header: "创建时间", key: "createdAt", width: 20 },
{ header: "ID", key: "id", width: 36 },
];
const rows = await prisma.invoicePool.findMany({
where,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: limit,
select: {
id: true,
status: true,
archived_reason: true,
owner_employee: { select: { name: true } },
uploaded_by_employee: { select: { name: true } },
invoice_code: true,
invoice_number: true,
invoice_date: true,
total_amount: true,
linked_reimbursement_id: true,
ocr_last_error: true,
created_at: true,
},
});
for (const item of rows) {
sheet.addRow({
status: item.status,
ownerName: item.owner_employee?.name ?? "",
uploadedByName: item.uploaded_by_employee?.name ?? "",
invoiceCode: item.invoice_code ?? "",
invoiceNumber: item.invoice_number ?? "",
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? "",
totalAmount: item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
archivedReason: item.archived_reason ?? "",
linkedReimbursementId: item.linked_reimbursement_id ?? "",
ocrLastError: item.ocr_last_error ?? "",
createdAt: item.created_at?.toISOString?.() ?? "",
id: item.id,
});
}
return workbook.xlsx.writeBuffer();
}
async function* streamAuditLogsCsv(prisma, where, limit) {
yield "\ufeff";
yield csvRow([
"createdAt",
"action",
"actorName",
"actorEmail",
"targetType",
"targetId",
"requestId",
"ipAddress",
]);
const batchSize = 1000;
let remaining = limit;
let cursorCreatedAt = null;
let cursorId = null;
while (remaining > 0) {
const take = Math.min(batchSize, remaining);
const rows = await prisma.auditLog.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,
request_id: true,
action: true,
target_type: true,
target_id: true,
ip_address: true,
created_at: true,
actor: { select: { name: true, email: true } },
},
});
if (!rows.length) {
break;
}
for (const item of rows) {
yield csvRow([
item.created_at?.toISOString?.() ?? "",
item.action,
item.actor?.name ?? "",
item.actor?.email ?? "",
item.target_type,
item.target_id ?? "",
item.request_id ?? "",
item.ip_address ?? "",
]);
remaining -= 1;
if (remaining <= 0) break;
}
const last = rows[rows.length - 1];
cursorCreatedAt = last.created_at;
cursorId = last.id;
}
}
async function buildAuditLogsXlsx(prisma, where, limit) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("audit_logs");
sheet.columns = [
{ header: "时间", key: "createdAt", width: 20 },
{ header: "Action", key: "action", width: 28 },
{ header: "Actor", key: "actorName", width: 14 },
{ header: "Actor Email", key: "actorEmail", width: 22 },
{ header: "Target Type", key: "targetType", width: 16 },
{ header: "Target ID", key: "targetId", width: 36 },
{ header: "Request ID", key: "requestId", width: 24 },
{ header: "IP", key: "ipAddress", width: 16 },
];
const rows = await prisma.auditLog.findMany({
where,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: limit,
select: {
id: true,
request_id: true,
action: true,
target_type: true,
target_id: true,
ip_address: true,
created_at: true,
actor: { select: { name: true, email: true } },
},
});
for (const item of rows) {
sheet.addRow({
createdAt: item.created_at?.toISOString?.() ?? "",
action: item.action,
actorName: item.actor?.name ?? "",
actorEmail: item.actor?.email ?? "",
targetType: item.target_type,
targetId: item.target_id ?? "",
requestId: item.request_id ?? "",
ipAddress: item.ip_address ?? "",
});
}
return workbook.xlsx.writeBuffer();
}
async function* streamEmployeesCsv(prisma, where, limit) {
yield "\ufeff";
yield csvRow(["工号", "姓名", "部门", "职位", "手机", "邮箱", "账号状态", "入职日期"]);
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.employee.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,
employee_no: true,
name: true,
phone: true,
email: true,
status: true,
title: true,
hire_date: true,
created_at: true,
department: { select: { name: true } },
},
});
if (!rows.length) {
break;
}
for (const item of rows) {
yield csvRow([
item.employee_no,
item.name,
item.department?.name ?? "",
item.title ?? "",
item.phone ?? "",
item.email ?? "",
item.status,
item.hire_date?.toISOString?.()?.slice(0, 10) ?? "",
]);
remaining -= 1;
if (remaining <= 0) break;
}
const last = rows[rows.length - 1];
cursorCreatedAt = last.created_at;
cursorId = last.id;
}
}
async function buildEmployeesXlsx(prisma, where, limit) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("employees");
sheet.columns = [
{ header: "工号", key: "employeeNo", width: 16 },
{ header: "姓名", key: "name", width: 14 },
{ header: "部门", key: "departmentName", width: 16 },
{ header: "职位", key: "title", width: 16 },
{ header: "手机", key: "phone", width: 16 },
{ header: "邮箱", key: "email", width: 24 },
{ header: "账号状态", key: "status", width: 12 },
{ header: "入职日期", key: "hireDate", width: 14 },
];
const rows = await prisma.employee.findMany({
where,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: limit,
select: {
employee_no: true,
name: true,
phone: true,
email: true,
status: true,
title: true,
hire_date: true,
department: { select: { name: true } },
},
});
for (const item of rows) {
sheet.addRow({
employeeNo: item.employee_no,
name: item.name,
departmentName: item.department?.name ?? "",
title: item.title ?? "",
phone: item.phone ?? "",
email: item.email ?? "",
status: item.status,
hireDate: item.hire_date?.toISOString?.()?.slice(0, 10) ?? "",
});
}
return workbook.xlsx.writeBuffer();
}
function buildPayslipExportHeader(salaryItems) {
return [
"工号",
"姓名",
"部门",
...salaryItems.map((item) => item.name),
"应发合计",
"应扣合计",
"实发合计",
];
}
function buildPayslipExportRow(payslip, salaryItems) {
const amountByItemId = new Map((payslip.items ?? []).map((item) => [item.salaryItemId, item.amount]));
return [
payslip.employee?.employeeNo ?? "",
payslip.employee?.name ?? "",
payslip.employee?.department?.name ?? "",
...salaryItems.map((item) => String(amountByItemId.get(item.id) ?? 0)),
String(payslip.grossAmount ?? 0),
String(payslip.deductionAmount ?? 0),
String(payslip.netAmount ?? 0),
];
}
function buildPayslipsCsv(dataset) {
const rows = ["\ufeff", csvRow(buildPayslipExportHeader(dataset.salaryItems))];
for (const payslip of dataset.payslips) {
rows.push(csvRow(buildPayslipExportRow(payslip, dataset.salaryItems)));
}
return rows.join("");
}
async function buildPayslipsXlsx(dataset) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("payslips");
sheet.columns = buildPayslipExportHeader(dataset.salaryItems).map((header) => ({
header,
key: header,
width: header.length > 8 ? 18 : 14,
}));
for (const payslip of dataset.payslips) {
const row = buildPayslipExportRow(payslip, dataset.salaryItems);
const rowObject = {};
buildPayslipExportHeader(dataset.salaryItems).forEach((header, index) => {
rowObject[header] = row[index];
});
sheet.addRow(rowObject);
}
return workbook.xlsx.writeBuffer();
}
function presenceStatusLabel(status) {
if (status === "office") return "在岗";
if (status === "remote") return "居家";
if (status === "out") return "外出";
if (status === "travel") return "出差";
if (status === "leave") return "请假";
return "未更新";
}
function presenceTimeRangeLabel(timeRange) {
if (timeRange === "morning") return "上午";
if (timeRange === "afternoon") return "下午";
if (timeRange === "full_day") return "全天";
return "-";
}
function presenceSourceLabel(source) {
if (source === "manual") return "手工";
if (source === "leave_sync") return "审批同步";
if (source === "admin_override") return "管理员修正";
return "-";
}
function mapPresenceHistoryExportRow(item) {
return [
item.date,
item.employee.employeeNo,
item.employee.name,
item.employee.department?.name ?? "",
item.employee.title ?? "",
presenceStatusLabel(item.presence?.status ?? null),
presenceTimeRangeLabel(item.presence?.timeRange),
presenceSourceLabel(item.presence?.source),
item.presence?.note ?? "-",
];
}
function buildPresenceHistoryCsv(items) {
const rows = [
"\ufeff",
csvRow(["日期", "工号", "姓名", "部门", "岗位", "状态", "时间段", "来源", "备注"]),
];
for (const item of items) {
rows.push(csvRow(mapPresenceHistoryExportRow(item)));
}
return rows.join("");
}
async function buildPresenceHistoryXlsx(items) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("presence_history");
sheet.columns = [
{ header: "日期", key: "date", width: 14 },
{ header: "工号", key: "employeeNo", width: 14 },
{ header: "姓名", key: "name", width: 14 },
{ header: "部门", key: "departmentName", width: 18 },
{ header: "岗位", key: "title", width: 18 },
{ header: "状态", key: "status", width: 12 },
{ header: "时间段", key: "timeRange", width: 12 },
{ header: "来源", key: "source", width: 14 },
{ header: "备注", key: "note", width: 32 },
];
for (const item of items) {
sheet.addRow({
date: item.date,
employeeNo: item.employee.employeeNo,
name: item.employee.name,
departmentName: item.employee.department?.name ?? "",
title: item.employee.title ?? "",
status: presenceStatusLabel(item.presence?.status ?? null),
timeRange: presenceTimeRangeLabel(item.presence?.timeRange),
source: presenceSourceLabel(item.presence?.source),
note: item.presence?.note ?? "-",
});
}
return workbook.xlsx.writeBuffer();
}
async function exportsRoutes(fastify) {
fastify.post(
"/exports/contracts-registry",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
await ensureContractsRegistryExportEnabled(fastify, request);
const parsedBody = parseBody(CONTRACT_REGISTRY_EXPORT_BODY_SCHEMA, request.body);
const format = parsedBody.format ?? "csv";
const filters = parseContractRegistryFilters({
projectId: parsedBody.projectId,
status: parsedBody.status,
contractType: parsedBody.contractType,
counterpartyName: parsedBody.counterpartyName,
expiryDateFrom: parsedBody.expiryDateFrom,
expiryDateTo: parsedBody.expiryDateTo,
});
const where = buildContractRegistryWhere({
filters,
employee: request.employee,
hasPermission: fastify.hasPermission,
timeZone: fastify.config.appTimezone,
});
const matchedTotal = await fastify.prisma.contractRegistry.count({ where });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_CONTRACTS_REGISTRY",
targetType: "export",
extra: {
format,
projectId: filters.projectId ?? null,
status: filters.status ?? null,
contractType: filters.contractType ?? null,
counterpartyName: filters.counterpartyName ?? null,
expiryDateFrom: filters.expiryDateFrom ?? null,
expiryDateTo: filters.expiryDateTo ?? null,
matchedTotal,
},
}),
});
const filename = exportFilename("contracts-registry", format);
const today = resolveContractRegistryToday(fastify.config.appTimezone);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildContractsRegistryXlsx(fastify.prisma, where, today);
return reply.send(Buffer.from(buffer));
}
const stream = Readable.from(streamContractsRegistryCsv(fastify.prisma, where, today));
return reply.send(stream);
},
);
fastify.post(
"/exports/ap-operations",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
await ensureApOperationsExportEnabled(fastify);
ensureHasAnyPermission(fastify, request, AP_OPERATIONS_PERMISSION_CODES);
const parsedBody = parseBody(AP_OPERATIONS_EXPORT_BODY_SCHEMA, request.body);
const format = parsedBody.format ?? "csv";
const filters = {
pendingEntityType: parsedBody.pendingEntityType,
pendingCreatedAtFrom: parsedBody.pendingCreatedAtFrom,
pendingCreatedAtTo: parsedBody.pendingCreatedAtTo,
riskBucket: parsedBody.riskBucket ?? "all",
ledgerStatus: parsedBody.ledgerStatus,
ledgerPaymentStatus: parsedBody.ledgerPaymentStatus,
ledgerInvoiceStatus: parsedBody.ledgerInvoiceStatus,
ledgerDateFrom: parsedBody.ledgerDateFrom,
ledgerDateTo: parsedBody.ledgerDateTo,
ledgerSearch: parsedBody.ledgerSearch,
};
const dataset = await loadApOperationsExportDataset(fastify.prisma, filters);
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_AP_OPERATIONS",
targetType: "export",
extra: {
format,
pendingEntityType: filters.pendingEntityType ?? null,
pendingCreatedAtFrom: filters.pendingCreatedAtFrom ?? null,
pendingCreatedAtTo: filters.pendingCreatedAtTo ?? null,
riskBucket: filters.riskBucket,
pendingPaymentBatchTotal: dataset.pendingPaymentBatches.length,
failedPaymentItemTotal: dataset.failedPaymentBatchItems.length,
openRiskFlagTotal: dataset.riskFlags.length,
reconciliationAnomalyTotal: dataset.reconciliationAnomalies.length,
financeLedgerTotal: dataset.reimbursementLedger.length,
ledgerStatus: filters.ledgerStatus ?? null,
ledgerPaymentStatus: filters.ledgerPaymentStatus ?? null,
ledgerInvoiceStatus: filters.ledgerInvoiceStatus ?? null,
ledgerDateFrom: filters.ledgerDateFrom ?? null,
ledgerDateTo: filters.ledgerDateTo ?? null,
},
}),
});
const filename = exportFilename("ap-operations", format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildApOperationsXlsx(dataset);
return reply.send(Buffer.from(buffer));
}
const stream = Readable.from(streamApOperationsCsv(dataset));
return reply.send(stream);
},
);
fastify.get(
"/exports/employees",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query);
const filters = parseEmployeeDirectoryFilters(query, { allowDepartmentFilter: true });
const where = buildEmployeeDirectoryWhere(filters);
const format = resolveExportFormat(query);
const limit = query.limit ?? 5000;
const total = await fastify.prisma.employee.count({ where });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_EMPLOYEES",
targetType: "export",
extra: {
format,
status: query.status ?? null,
departmentId: query.departmentId ?? null,
q: query.q?.trim() || null,
title: query.title?.trim() || null,
managerId: query.managerId ?? null,
hireDateFrom: query.hireDateFrom ?? null,
hireDateTo: query.hireDateTo ?? null,
employmentStatus: query.employmentStatus ?? null,
limit,
matchedTotal: total,
},
}),
});
const filename = exportFilename("employees", format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildEmployeesXlsx(fastify.prisma, where, limit);
return reply.send(Buffer.from(buffer));
}
const stream = Readable.from(streamEmployeesCsv(fastify.prisma, where, limit));
return reply.send(stream);
},
);
fastify.get(
"/exports/payslips",
{
preHandler: fastify.requirePermission("payroll.manage"),
},
async (request, reply) => {
const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query);
const month = parsePayMonth(query.month);
if (!month) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { month: "invalid_month" },
});
}
const dataset = await loadMonthlyPayslips(fastify.prisma, month.startDate);
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_PAYSLIPS",
targetType: "export",
extra: {
format: resolveExportFormat(query),
month: query.month,
matchedTotal: dataset.payslips.length,
},
}),
});
const format = resolveExportFormat(query);
const filename = exportFilename(`payslips-${query.month}`, format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildPayslipsXlsx(dataset);
return reply.send(Buffer.from(buffer));
}
return reply.send(buildPayslipsCsv(dataset));
},
);
fastify.get(
"/exports/presence-history",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const query = parseQuery(PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA, request.query);
const format = resolveExportFormat(query);
const range = resolvePresenceHistoryRange(query, fastify.config.appTimezone);
const history = await buildTeamPresenceHistory(fastify.prisma, {
dateStrings: range.dateStrings,
departmentId: query.departmentId,
employeeId: query.employeeId,
status: query.status,
});
if (history.items.length > PRESENCE_HISTORY_EXPORT_LIMIT) {
throw buildPresenceValidationError({ export: "too_large" }, 409);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_PRESENCE_HISTORY",
targetType: "export",
extra: {
format,
dateFrom: range.dateFrom,
dateTo: range.dateTo,
departmentId: query.departmentId ?? null,
employeeId: query.employeeId ?? null,
status: query.status ?? null,
matchedTotal: history.items.length,
},
}),
});
const filename = exportFilename("presence-history", format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildPresenceHistoryXlsx(history.items);
return reply.send(Buffer.from(buffer));
}
return reply.send(buildPresenceHistoryCsv(history.items));
},
);
fastify.get(
"/exports/reimbursements",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensureHasAnyPermission(fastify, request, ["expense.view_own", "expense.view_all"]);
const query = parseQuery(REIMBURSEMENT_EXPORT_QUERY_SCHEMA, request.query);
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
const scope = query.scope ?? (canViewAll ? "all" : "own");
if (scope === "all" && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const where = {
...(query.status ? { status: query.status } : {}),
...(scope === "all"
? buildViewAllScopeWhere(request)
: buildOwnReimbursementScopeWhere(request.employee.id)),
};
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_REIMBURSEMENTS",
targetType: "export",
extra: {
format,
scope,
status: query.status ?? null,
limit,
matchedTotal: total,
},
}),
});
const filename = exportFilename("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(streamReimbursementsCsv(fastify.prisma, where, limit));
return reply.send(stream);
},
);
fastify.get(
"/exports/invoice-pool",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensureHasAnyPermission(fastify, request, ["invoice.view_own", "invoice.manage"]);
const query = parseQuery(INVOICE_EXPORT_QUERY_SCHEMA, request.query);
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
const ownerScope = query.ownerScope ?? (canManage ? "all" : "own");
if (ownerScope === "all" && !canManage) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const where = {
...(query.status ? { status: query.status } : {}),
...(ownerScope === "all"
? {}
: {
OR: [
{ owner_employee_id: request.employee.id },
{ uploaded_by_employee_id: request.employee.id },
{
linked_reimbursement: {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
],
},
},
],
}),
};
const format = resolveExportFormat(query);
const limit = query.limit ?? 5000;
const total = await fastify.prisma.invoicePool.count({ where });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_INVOICE_POOL",
targetType: "export",
extra: {
format,
ownerScope,
status: query.status ?? null,
limit,
matchedTotal: total,
},
}),
});
const filename = exportFilename("invoice-pool", format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildInvoicesXlsx(fastify.prisma, where, limit);
return reply.send(Buffer.from(buffer));
}
const stream = Readable.from(streamInvoicesCsv(fastify.prisma, where, limit));
return reply.send(stream);
},
);
fastify.get(
"/exports/audit-logs",
{
preHandler: fastify.requirePermission("system.audit.view"),
},
async (request, reply) => {
const query = parseQuery(AUDIT_EXPORT_QUERY_SCHEMA, request.query);
const dateFrom = query.dateFrom ? new Date(query.dateFrom) : null;
const dateTo = query.dateTo ? new Date(query.dateTo) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
const where = {
...(query.actorId ? { actor_id: query.actorId } : {}),
...(query.action ? { action: query.action } : {}),
...(query.targetType ? { target_type: query.targetType } : {}),
...(dateFrom || dateTo
? {
created_at: {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {}),
},
}
: {}),
};
const format = resolveExportFormat(query);
const limit = query.limit ?? 20000;
const total = await fastify.prisma.auditLog.count({ where });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_AUDIT_LOGS",
targetType: "export",
extra: {
format,
actorId: query.actorId ?? null,
actionFilter: query.action ?? null,
targetType: query.targetType ?? null,
dateFrom: dateFrom?.toISOString?.() ?? null,
dateTo: dateTo?.toISOString?.() ?? null,
limit,
matchedTotal: total,
},
}),
});
const filename = exportFilename("audit-logs", format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildAuditLogsXlsx(fastify.prisma, where, limit);
return reply.send(Buffer.from(buffer));
}
const stream = Readable.from(streamAuditLogsCsv(fastify.prisma, where, limit));
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;