R49 合同台账增强:金额分类、筛选、到期预警、续约与导出
这个提交包含在:
@@ -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",
|
||||
{
|
||||
|
||||
在新工单中引用
屏蔽一个用户