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