R49 合同台账增强:金额分类、筛选、到期预警、续约与导出

这个提交包含在:
2026-04-05 11:21:15 +08:00
父节点 a960c9b57f
当前提交 ff0a677bf3
共修改 10 个文件,包含 2214 行新增和 168 行删除
+250
查看文件
@@ -3,11 +3,21 @@ 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 { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
HISTORY_STATUS_VALUES,
buildPresenceValidationError,
@@ -88,6 +98,11 @@ const PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA = z.object({
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 PRESENCE_HISTORY_EXPORT_LIMIT = 5000;
function csvCell(value) {
@@ -118,6 +133,20 @@ function parseQuery(schema, query) {
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";
}
@@ -302,6 +331,170 @@ function setExportHeaders(reply, format, filename) {
reply.header("content-disposition", `attachment; filename=\"${filename}\"`);
}
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 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([
@@ -952,6 +1145,63 @@ async function buildPresenceHistoryXlsx(items) {
}
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.get(
"/exports/employees",
{