2097 行
63 KiB
JavaScript
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;
|