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

这个提交包含在:
2026-04-05 11:21:15 +08:00
父节点 a960c9b57f
当前提交 ff0a677bf3
共修改 10 个文件,包含 2214 行新增和 168 行删除
@@ -0,0 +1,233 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const CONTRACT_REGISTRY_TYPE_VALUES = ["purchase", "sales", "service", "framework", "other"];
const CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES = ["draft", "active", "archived"];
const CONTRACT_REGISTRY_EFFECTIVE_STATUS_VALUES = [...CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES, "expired"];
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA = z
.object({
projectId: z.string().uuid().optional(),
status: z.enum(CONTRACT_REGISTRY_EFFECTIVE_STATUS_VALUES).optional(),
contractType: z.enum(CONTRACT_REGISTRY_TYPE_VALUES).optional(),
counterpartyName: z.string().trim().max(200).optional(),
expiryDateFrom: DATE_ONLY_SCHEMA.optional(),
expiryDateTo: DATE_ONLY_SCHEMA.optional(),
})
.strict();
const CONTRACT_REGISTRY_EXPIRING_QUERY_SCHEMA = z
.object({
days: z.coerce.number().int().min(1).max(365).optional(),
})
.strict();
function buildContractRegistryValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseContractRegistryFilters(input) {
const parsed = CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA.safeParse(input ?? {});
if (!parsed.success) {
throw buildContractRegistryValidationError(parsed.error.flatten());
}
const filters = {
...parsed.data,
counterpartyName: parsed.data.counterpartyName?.trim() || undefined,
};
if (filters.expiryDateFrom && filters.expiryDateTo) {
const start = parseDateOnlyString(filters.expiryDateFrom);
const end = parseDateOnlyString(filters.expiryDateTo);
if (start > end) {
throw buildContractRegistryValidationError({
expiryDateTo: "must_be_after_or_equal_expiryDateFrom",
});
}
}
return filters;
}
function parseContractRegistryExpiringQuery(input) {
const parsed = CONTRACT_REGISTRY_EXPIRING_QUERY_SCHEMA.safeParse(input ?? {});
if (!parsed.success) {
throw buildContractRegistryValidationError(parsed.error.flatten());
}
return {
days: parsed.data.days ?? 30,
};
}
function resolveContractRegistryToday(timeZone) {
return parseDateOnlyString(currentDateString(timeZone ?? "Asia/Shanghai"));
}
function addUtcDays(dateValue, days) {
const next = new Date(dateValue.getTime());
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function canManageContractRegistry(hasPermission, employee) {
return (
hasPermission(employee, "project.manage") ||
hasPermission(employee, "project.view_all") ||
hasPermission(employee, "system.settings")
);
}
function buildContractRegistryScopeWhere({ employee, hasPermission }) {
if (canManageContractRegistry(hasPermission, employee)) {
return {};
}
return {
OR: [
{ owner_employee_id: employee.id },
{ project: { owner_employee_id: employee.id } },
{ project: { manager_employee_id: employee.id } },
{ project: { members: { some: { employee_id: employee.id, left_at: null } } } },
],
};
}
function buildEffectiveStatusWhere(status, today) {
if (!status) {
return null;
}
if (status === "draft") {
return { status: "draft" };
}
if (status === "archived") {
return { status: "archived" };
}
if (status === "expired") {
return {
status: "active",
expiry_date: { lt: today },
};
}
return {
status: "active",
OR: [{ expiry_date: null }, { expiry_date: { gte: today } }],
};
}
function buildContractRegistryWhere({ filters, employee, hasPermission, timeZone }) {
const today = resolveContractRegistryToday(timeZone);
const conditions = [];
const scopeWhere = buildContractRegistryScopeWhere({ employee, hasPermission });
if (Object.keys(scopeWhere).length) {
conditions.push(scopeWhere);
}
if (filters.projectId) {
conditions.push({ project_id: filters.projectId });
}
if (filters.contractType) {
conditions.push({ contract_type: filters.contractType });
}
if (filters.counterpartyName) {
conditions.push({
counterparty_name: {
contains: filters.counterpartyName,
mode: "insensitive",
},
});
}
if (filters.expiryDateFrom || filters.expiryDateTo) {
conditions.push({
expiry_date: {
...(filters.expiryDateFrom ? { gte: parseDateOnlyString(filters.expiryDateFrom) } : {}),
...(filters.expiryDateTo ? { lte: parseDateOnlyString(filters.expiryDateTo) } : {}),
},
});
}
const statusWhere = buildEffectiveStatusWhere(filters.status, today);
if (statusWhere) {
conditions.push(statusWhere);
}
return conditions.length ? { AND: conditions } : {};
}
function buildContractRegistryExpiringWhere({ employee, hasPermission, timeZone, days }) {
const today = resolveContractRegistryToday(timeZone);
const conditions = [];
const scopeWhere = buildContractRegistryScopeWhere({ employee, hasPermission });
if (Object.keys(scopeWhere).length) {
conditions.push(scopeWhere);
}
conditions.push({ status: "active" });
conditions.push({
expiry_date: {
gte: today,
lte: addUtcDays(today, days),
},
});
return { AND: conditions };
}
function resolveContractRegistryEffectiveStatus(item, todayOrTimeZone) {
const today = todayOrTimeZone instanceof Date ? todayOrTimeZone : resolveContractRegistryToday(todayOrTimeZone);
if (!item || item.status !== "active" || !item.expiry_date) {
return item?.status ?? "draft";
}
return item.expiry_date < today ? "expired" : item.status;
}
function formatContractRegistryDateOnly(value) {
return value?.toISOString?.()?.slice(0, 10) ?? null;
}
function formatContractRegistryAmount(value) {
if (value == null) {
return null;
}
if (typeof value === "number") {
return value.toFixed(2);
}
if (typeof value === "string") {
return value;
}
if (typeof value.toFixed === "function") {
return value.toFixed(2);
}
return String(value);
}
module.exports = {
CONTRACT_REGISTRY_EFFECTIVE_STATUS_VALUES,
CONTRACT_REGISTRY_EXPIRING_QUERY_SCHEMA,
CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA,
CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES,
CONTRACT_REGISTRY_TYPE_VALUES,
buildContractRegistryExpiringWhere,
buildContractRegistryValidationError,
buildContractRegistryWhere,
canManageContractRegistry,
formatContractRegistryAmount,
formatContractRegistryDateOnly,
parseContractRegistryExpiringQuery,
parseContractRegistryFilters,
resolveContractRegistryEffectiveStatus,
resolveContractRegistryToday,
};