|
|
|
@@ -1,9 +1,9 @@
|
|
|
|
|
const { z } = require("zod");
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES,
|
|
|
|
|
CONTRACT_REGISTRY_TYPE_VALUES,
|
|
|
|
|
buildContractRegistryExpiringWhere,
|
|
|
|
|
buildContractRegistryScopeWhere,
|
|
|
|
|
buildContractRegistryValidationError,
|
|
|
|
|
buildContractRegistryWhere,
|
|
|
|
|
canManageContractRegistry,
|
|
|
|
@@ -24,7 +24,8 @@ const UUID_SCHEMA = z.string().uuid();
|
|
|
|
|
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
|
|
|
const CONFIDENTIALITY_SCHEMA = z.enum(["public", "internal", "confidential", "restricted"]);
|
|
|
|
|
const CONTRACT_TYPE_SCHEMA = z.enum(CONTRACT_REGISTRY_TYPE_VALUES);
|
|
|
|
|
const STATUS_WRITE_SCHEMA = z.enum(CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES);
|
|
|
|
|
const GENERAL_WRITE_STATUS_VALUES = ["draft", "active", "archived"];
|
|
|
|
|
const STATUS_WRITE_SCHEMA = z.enum(GENERAL_WRITE_STATUS_VALUES);
|
|
|
|
|
const MONEY_INPUT_SCHEMA = z.string().regex(/^\d{1,12}(\.\d{1,2})?$/);
|
|
|
|
|
const CURRENCY_SCHEMA = z
|
|
|
|
|
.string()
|
|
|
|
@@ -57,11 +58,51 @@ const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value)
|
|
|
|
|
message: "empty_patch",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const TERMINATE_SCHEMA = z
|
|
|
|
|
.object({
|
|
|
|
|
reason: z.string().trim().min(10).max(1000),
|
|
|
|
|
})
|
|
|
|
|
.strict();
|
|
|
|
|
|
|
|
|
|
const CORRECTION_SCHEMA = z
|
|
|
|
|
.object({
|
|
|
|
|
summary: z.string().trim().min(10).max(1000),
|
|
|
|
|
})
|
|
|
|
|
.strict();
|
|
|
|
|
|
|
|
|
|
const CONTRACT_REGISTRY_INCLUDE = {
|
|
|
|
|
owner: { select: { id: true, employee_no: true, name: true } },
|
|
|
|
|
project: { select: { id: true, project_no: true, name: true } },
|
|
|
|
|
terminator: { select: { id: true, employee_no: true, name: true } },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const CONTRACT_REGISTRY_DETAIL_INCLUDE = {
|
|
|
|
|
...CONTRACT_REGISTRY_INCLUDE,
|
|
|
|
|
document_refs: {
|
|
|
|
|
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
document_no: true,
|
|
|
|
|
confidentiality_level: true,
|
|
|
|
|
storage_type: true,
|
|
|
|
|
offline_archive_no: true,
|
|
|
|
|
created_at: true,
|
|
|
|
|
updated_at: true,
|
|
|
|
|
owner: { select: { id: true, employee_no: true, name: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const CONTRACT_HISTORY_ACTIONS = [
|
|
|
|
|
"CONTRACT_REGISTRY_CREATED",
|
|
|
|
|
"CONTRACT_REGISTRY_UPDATED",
|
|
|
|
|
"CONTRACT_REGISTRY_STATUS_CHANGED",
|
|
|
|
|
"CONTRACT_REGISTRY_RENEWED",
|
|
|
|
|
"CONTRACT_REGISTRY_TERMINATED",
|
|
|
|
|
"CONTRACT_REGISTRY_CORRECTION_APPENDED",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function parseBody(schema, body) {
|
|
|
|
|
const parsed = schema.safeParse(body ?? {});
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
@@ -102,11 +143,78 @@ async function writeAudit(prisma, request, action, targetId, extra = {}) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeRequiredAudit(prisma, request, action, targetId, extra = {}) {
|
|
|
|
|
return prisma.auditLog.create({
|
|
|
|
|
data: buildAuditLogData(request, {
|
|
|
|
|
actorId: request.employee.id,
|
|
|
|
|
action,
|
|
|
|
|
targetType: "contract_registry",
|
|
|
|
|
targetId,
|
|
|
|
|
extra,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseOptionalDate(value) {
|
|
|
|
|
return value ? parseDateOnlyString(value) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapEmployee(item) {
|
|
|
|
|
return item ? { id: item.id, employeeNo: item.employee_no, name: item.name } : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertTerminationInvariant(item) {
|
|
|
|
|
const terminationFields = [item?.terminated_at, item?.terminated_reason, item?.terminated_by];
|
|
|
|
|
const allPresent = terminationFields.every((value) => value !== null && value !== undefined && value !== "");
|
|
|
|
|
const anyPresent = terminationFields.some((value) => value !== null && value !== undefined && value !== "");
|
|
|
|
|
const valid = item?.status === "terminated" ? allPresent : !anyPresent;
|
|
|
|
|
if (!valid) {
|
|
|
|
|
throw new AppError({
|
|
|
|
|
code: "SYS_001",
|
|
|
|
|
message: "合同状态数据不一致",
|
|
|
|
|
statusCode: 500,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function contractStatusLabel(value) {
|
|
|
|
|
if (value === "draft") return "草稿";
|
|
|
|
|
if (value === "active") return "生效中";
|
|
|
|
|
if (value === "expired") return "已过期";
|
|
|
|
|
if (value === "archived") return "已归档";
|
|
|
|
|
if (value === "terminated") return "已终止";
|
|
|
|
|
return "未知状态";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapHistorySummary(item) {
|
|
|
|
|
const extra = item.extra && typeof item.extra === "object" && !Array.isArray(item.extra) ? item.extra : {};
|
|
|
|
|
if (item.action === "CONTRACT_REGISTRY_CREATED") return "创建合同台账";
|
|
|
|
|
if (item.action === "CONTRACT_REGISTRY_UPDATED") return "更新合同信息";
|
|
|
|
|
if (item.action === "CONTRACT_REGISTRY_RENEWED") {
|
|
|
|
|
return extra.renewedContractNo ? `续约为 ${String(extra.renewedContractNo)}` : "创建续约草稿";
|
|
|
|
|
}
|
|
|
|
|
if (item.action === "CONTRACT_REGISTRY_TERMINATED") {
|
|
|
|
|
const reason = extra.reasonSummary ?? extra.reason;
|
|
|
|
|
return reason ? `终止原因:${String(reason)}` : "合同已终止";
|
|
|
|
|
}
|
|
|
|
|
if (item.action === "CONTRACT_REGISTRY_CORRECTION_APPENDED") {
|
|
|
|
|
return extra.summary ? `追加修正:${String(extra.summary)}` : "追加归档修正记录";
|
|
|
|
|
}
|
|
|
|
|
if (item.action === "CONTRACT_REGISTRY_STATUS_CHANGED") {
|
|
|
|
|
const fromStatus = contractStatusLabel(extra.fromStatus);
|
|
|
|
|
const toStatus = contractStatusLabel(extra.toStatus);
|
|
|
|
|
return `状态由“${fromStatus}”变为“${toStatus}”`;
|
|
|
|
|
}
|
|
|
|
|
return "合同记录已更新";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAuditReasonSummary(value) {
|
|
|
|
|
const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
|
|
|
return normalized.length > 160 ? `${normalized.slice(0, 160)}…` : normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapRow(item, today) {
|
|
|
|
|
assertTerminationInvariant(item);
|
|
|
|
|
return {
|
|
|
|
|
id: item.id,
|
|
|
|
|
contractNo: item.contract_no,
|
|
|
|
@@ -125,17 +233,63 @@ function mapRow(item, today) {
|
|
|
|
|
ownerEmployeeId: item.owner_employee_id,
|
|
|
|
|
status: resolveContractRegistryEffectiveStatus(item, today),
|
|
|
|
|
persistedStatus: item.status,
|
|
|
|
|
terminatedAt: item.terminated_at?.toISOString?.() ?? null,
|
|
|
|
|
terminatedReason: item.terminated_reason ?? null,
|
|
|
|
|
terminatedBy: item.terminated_by ?? null,
|
|
|
|
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
|
|
|
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
|
|
|
|
owner: item.owner
|
|
|
|
|
? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name }
|
|
|
|
|
: null,
|
|
|
|
|
owner: mapEmployee(item.owner),
|
|
|
|
|
terminator: mapEmployee(item.terminator),
|
|
|
|
|
project: item.project
|
|
|
|
|
? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name }
|
|
|
|
|
: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapListRow(item, today) {
|
|
|
|
|
const row = mapRow(item, today);
|
|
|
|
|
const {
|
|
|
|
|
terminatedAt: _terminatedAt,
|
|
|
|
|
terminatedReason: _terminatedReason,
|
|
|
|
|
terminatedBy: _terminatedBy,
|
|
|
|
|
terminator: _terminator,
|
|
|
|
|
...listRow
|
|
|
|
|
} = row;
|
|
|
|
|
return listRow;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapDocumentRef(item) {
|
|
|
|
|
return {
|
|
|
|
|
id: item.id,
|
|
|
|
|
title: item.title,
|
|
|
|
|
documentNo: item.document_no ?? null,
|
|
|
|
|
confidentialityLevel: item.confidentiality_level,
|
|
|
|
|
storageType: item.storage_type,
|
|
|
|
|
offlineArchiveNo: item.offline_archive_no ?? null,
|
|
|
|
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
|
|
|
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
|
|
|
|
owner: mapEmployee(item.owner),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapHistoryItem(item) {
|
|
|
|
|
return {
|
|
|
|
|
id: item.id,
|
|
|
|
|
action: item.action,
|
|
|
|
|
summary: mapHistorySummary(item),
|
|
|
|
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
|
|
|
|
actor: mapEmployee(item.actor),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildScopedContractWhere(fastify, request, contractId) {
|
|
|
|
|
const scopeWhere = buildContractRegistryScopeWhere({
|
|
|
|
|
employee: request.employee,
|
|
|
|
|
hasPermission: fastify.hasPermission,
|
|
|
|
|
});
|
|
|
|
|
return Object.keys(scopeWhere).length ? { AND: [{ id: contractId }, scopeWhere] } : { id: contractId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildNotFoundError() {
|
|
|
|
|
return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
|
|
|
}
|
|
|
|
@@ -154,6 +308,57 @@ function buildRenewalContractNo(baseContractNo, rows) {
|
|
|
|
|
return `${baseContractNo}-R${maxRenewalIndex + 1}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildContractPatchData(body) {
|
|
|
|
|
return {
|
|
|
|
|
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
|
|
|
|
|
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
|
|
|
|
|
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
|
|
|
|
|
...(body.counterpartyName !== undefined ? { counterparty_name: body.counterpartyName } : {}),
|
|
|
|
|
...(body.summary !== undefined ? { summary: body.summary } : {}),
|
|
|
|
|
...(body.amount !== undefined ? { amount: body.amount ?? null } : {}),
|
|
|
|
|
...(body.currency !== undefined ? { currency: body.currency } : {}),
|
|
|
|
|
...(body.signedDate !== undefined ? { signed_date: parseOptionalDate(body.signedDate) } : {}),
|
|
|
|
|
...(body.effectiveDate !== undefined ? { effective_date: parseOptionalDate(body.effectiveDate) } : {}),
|
|
|
|
|
...(body.expiryDate !== undefined ? { expiry_date: parseOptionalDate(body.expiryDate) } : {}),
|
|
|
|
|
...(body.confidentialityLevel !== undefined ? { confidentiality_level: body.confidentialityLevel } : {}),
|
|
|
|
|
...(body.archiveLocation !== undefined ? { archive_location: body.archiveLocation } : {}),
|
|
|
|
|
...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}),
|
|
|
|
|
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
|
|
|
|
...(body.status !== undefined ? { status: body.status } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildContractStateGuard(item) {
|
|
|
|
|
return {
|
|
|
|
|
id: item.id,
|
|
|
|
|
status: item.status,
|
|
|
|
|
updated_at: item.updated_at,
|
|
|
|
|
terminated_at: item.terminated_at ?? null,
|
|
|
|
|
terminated_reason: item.terminated_reason ?? null,
|
|
|
|
|
terminated_by: item.terminated_by ?? null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateContractWithStateGuard(prisma, existing, data) {
|
|
|
|
|
const changed = await prisma.contractRegistry.updateMany({
|
|
|
|
|
where: buildContractStateGuard(existing),
|
|
|
|
|
data,
|
|
|
|
|
});
|
|
|
|
|
if (changed.count !== 1) {
|
|
|
|
|
throw buildContractRegistryValidationError({ status: "contract_state_conflict" }, 409);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = await prisma.contractRegistry.findUnique({
|
|
|
|
|
where: { id: existing.id },
|
|
|
|
|
include: CONTRACT_REGISTRY_INCLUDE,
|
|
|
|
|
});
|
|
|
|
|
if (!updated) {
|
|
|
|
|
throw buildNotFoundError();
|
|
|
|
|
}
|
|
|
|
|
assertTerminationInvariant(updated);
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function contractsRegistryRoutes(fastify) {
|
|
|
|
|
fastify.get("/contracts-registry", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
|
|
|
await ensureFeatureEnabled(fastify, request);
|
|
|
|
@@ -170,7 +375,7 @@ async function contractsRegistryRoutes(fastify) {
|
|
|
|
|
include: CONTRACT_REGISTRY_INCLUDE,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return sendData(reply, request, { items: rows.map((item) => mapRow(item, today)) });
|
|
|
|
|
return sendData(reply, request, { items: rows.map((item) => mapListRow(item, today)) });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fastify.get("/contracts-registry/expiring", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
|
|
@@ -191,7 +396,39 @@ async function contractsRegistryRoutes(fastify) {
|
|
|
|
|
return sendData(reply, request, {
|
|
|
|
|
days,
|
|
|
|
|
total: rows.length,
|
|
|
|
|
items: rows.map((item) => mapRow(item, today)),
|
|
|
|
|
items: rows.map((item) => mapListRow(item, today)),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fastify.get("/contracts-registry/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
|
|
|
await ensureFeatureEnabled(fastify, request);
|
|
|
|
|
const contractId = UUID_SCHEMA.parse(request.params?.id);
|
|
|
|
|
const today = resolveContractRegistryToday(fastify.config.appTimezone);
|
|
|
|
|
const item = await fastify.prisma.contractRegistry.findFirst({
|
|
|
|
|
where: buildScopedContractWhere(fastify, request, contractId),
|
|
|
|
|
include: CONTRACT_REGISTRY_DETAIL_INCLUDE,
|
|
|
|
|
});
|
|
|
|
|
if (!item) {
|
|
|
|
|
throw buildNotFoundError();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const history = await fastify.prisma.auditLog.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
target_type: "contract_registry",
|
|
|
|
|
target_id: contractId,
|
|
|
|
|
action: { in: CONTRACT_HISTORY_ACTIONS },
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
|
|
|
|
take: 100,
|
|
|
|
|
include: { actor: { select: { id: true, employee_no: true, name: true } } },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return sendData(reply, request, {
|
|
|
|
|
item: {
|
|
|
|
|
...mapRow(item, today),
|
|
|
|
|
documentRefs: (item.document_refs ?? []).map(mapDocumentRef),
|
|
|
|
|
history: history.map(mapHistoryItem),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -253,50 +490,153 @@ async function contractsRegistryRoutes(fastify) {
|
|
|
|
|
const body = parseBody(PATCH_SCHEMA, request.body);
|
|
|
|
|
const existing = await fastify.prisma.contractRegistry.findUnique({
|
|
|
|
|
where: { id: contractId },
|
|
|
|
|
select: { id: true, status: true, contract_no: true },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
status: true,
|
|
|
|
|
contract_no: true,
|
|
|
|
|
terminated_at: true,
|
|
|
|
|
terminated_reason: true,
|
|
|
|
|
terminated_by: true,
|
|
|
|
|
updated_at: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!existing) {
|
|
|
|
|
throw buildNotFoundError();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assertTerminationInvariant(existing);
|
|
|
|
|
if (existing.status === "terminated") {
|
|
|
|
|
throw buildContractRegistryValidationError({ status: "terminated_is_final" }, 409);
|
|
|
|
|
}
|
|
|
|
|
const patchFields = Object.keys(body);
|
|
|
|
|
if (existing.status === "archived" && !(patchFields.length === 1 && body.status === "active")) {
|
|
|
|
|
throw buildContractRegistryValidationError({ status: "archived_fields_locked" }, 409);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const today = resolveContractRegistryToday(fastify.config.appTimezone);
|
|
|
|
|
const updated = await fastify.prisma.contractRegistry.update({
|
|
|
|
|
where: { id: contractId },
|
|
|
|
|
data: {
|
|
|
|
|
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
|
|
|
|
|
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
|
|
|
|
|
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
|
|
|
|
|
...(body.counterpartyName !== undefined ? { counterparty_name: body.counterpartyName } : {}),
|
|
|
|
|
...(body.summary !== undefined ? { summary: body.summary } : {}),
|
|
|
|
|
...(body.amount !== undefined ? { amount: body.amount ?? null } : {}),
|
|
|
|
|
...(body.currency !== undefined ? { currency: body.currency } : {}),
|
|
|
|
|
...(body.signedDate !== undefined ? { signed_date: parseOptionalDate(body.signedDate) } : {}),
|
|
|
|
|
...(body.effectiveDate !== undefined ? { effective_date: parseOptionalDate(body.effectiveDate) } : {}),
|
|
|
|
|
...(body.expiryDate !== undefined ? { expiry_date: parseOptionalDate(body.expiryDate) } : {}),
|
|
|
|
|
...(body.confidentialityLevel !== undefined
|
|
|
|
|
? { confidentiality_level: body.confidentialityLevel }
|
|
|
|
|
: {}),
|
|
|
|
|
...(body.archiveLocation !== undefined ? { archive_location: body.archiveLocation } : {}),
|
|
|
|
|
...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}),
|
|
|
|
|
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
|
|
|
|
...(body.status !== undefined ? { status: body.status } : {}),
|
|
|
|
|
},
|
|
|
|
|
include: CONTRACT_REGISTRY_INCLUDE,
|
|
|
|
|
const statusChanged = body.status !== undefined && body.status !== existing.status;
|
|
|
|
|
const updated = await fastify.prisma.$transaction(async (tx) => {
|
|
|
|
|
const next = await updateContractWithStateGuard(tx, existing, buildContractPatchData(body));
|
|
|
|
|
if (statusChanged) {
|
|
|
|
|
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_STATUS_CHANGED", next.id, {
|
|
|
|
|
fromStatus: existing.status,
|
|
|
|
|
toStatus: body.status,
|
|
|
|
|
});
|
|
|
|
|
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_UPDATED", next.id, {
|
|
|
|
|
fields: patchFields,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await writeAudit(fastify.prisma, request, "CONTRACT_REGISTRY_UPDATED", updated.id, {
|
|
|
|
|
fields: Object.keys(body),
|
|
|
|
|
});
|
|
|
|
|
if (body.status !== undefined && body.status !== existing.status) {
|
|
|
|
|
await writeAudit(fastify.prisma, request, "CONTRACT_REGISTRY_STATUS_CHANGED", updated.id, {
|
|
|
|
|
fromStatus: existing.status,
|
|
|
|
|
toStatus: body.status,
|
|
|
|
|
if (!statusChanged) {
|
|
|
|
|
await writeAudit(fastify.prisma, request, "CONTRACT_REGISTRY_UPDATED", updated.id, {
|
|
|
|
|
fields: patchFields,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sendData(reply, request, { item: mapRow(updated, today) });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fastify.post("/contracts-registry/:id/terminate", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
|
|
|
await ensureFeatureEnabled(fastify, request);
|
|
|
|
|
if (!canManageContractRegistry(fastify.hasPermission, request.employee)) {
|
|
|
|
|
throw new AppError({
|
|
|
|
|
code: "PERM_001",
|
|
|
|
|
message: "无权限",
|
|
|
|
|
statusCode: 403,
|
|
|
|
|
details: { permission: "project.manage" },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contractId = UUID_SCHEMA.parse(request.params?.id);
|
|
|
|
|
const body = parseBody(TERMINATE_SCHEMA, request.body);
|
|
|
|
|
const today = resolveContractRegistryToday(fastify.config.appTimezone);
|
|
|
|
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
|
|
|
|
const existing = await tx.contractRegistry.findUnique({
|
|
|
|
|
where: { id: contractId },
|
|
|
|
|
include: CONTRACT_REGISTRY_INCLUDE,
|
|
|
|
|
});
|
|
|
|
|
if (!existing) {
|
|
|
|
|
throw buildNotFoundError();
|
|
|
|
|
}
|
|
|
|
|
assertTerminationInvariant(existing);
|
|
|
|
|
|
|
|
|
|
const effectiveStatus = resolveContractRegistryEffectiveStatus(existing, today);
|
|
|
|
|
if (!["active", "expired"].includes(effectiveStatus)) {
|
|
|
|
|
throw buildContractRegistryValidationError({ status: "terminate_not_allowed" }, 409);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const changed = await tx.contractRegistry.updateMany({
|
|
|
|
|
where: buildContractStateGuard(existing),
|
|
|
|
|
data: {
|
|
|
|
|
status: "terminated",
|
|
|
|
|
terminated_at: new Date(),
|
|
|
|
|
terminated_reason: body.reason,
|
|
|
|
|
terminated_by: request.employee.id,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (changed.count !== 1) {
|
|
|
|
|
throw buildContractRegistryValidationError({ status: "terminate_conflict" }, 409);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = await tx.contractRegistry.findUnique({
|
|
|
|
|
where: { id: contractId },
|
|
|
|
|
include: CONTRACT_REGISTRY_INCLUDE,
|
|
|
|
|
});
|
|
|
|
|
assertTerminationInvariant(updated);
|
|
|
|
|
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", contractId, {
|
|
|
|
|
contractNo: existing.contract_no,
|
|
|
|
|
fromStatus: existing.status,
|
|
|
|
|
toStatus: "terminated",
|
|
|
|
|
reasonSummary: buildAuditReasonSummary(body.reason),
|
|
|
|
|
});
|
|
|
|
|
return updated;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return sendData(reply, request, { item: mapRow(result, today) });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fastify.post("/contracts-registry/:id/corrections", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
|
|
|
await ensureFeatureEnabled(fastify, request);
|
|
|
|
|
if (!canManageContractRegistry(fastify.hasPermission, request.employee)) {
|
|
|
|
|
throw new AppError({
|
|
|
|
|
code: "PERM_001",
|
|
|
|
|
message: "无权限",
|
|
|
|
|
statusCode: 403,
|
|
|
|
|
details: { permission: "project.manage" },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contractId = UUID_SCHEMA.parse(request.params?.id);
|
|
|
|
|
const body = parseBody(CORRECTION_SCHEMA, request.body);
|
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
|
|
|
const item = await tx.contractRegistry.findUnique({
|
|
|
|
|
where: { id: contractId },
|
|
|
|
|
select: { id: true, status: true, updated_at: true },
|
|
|
|
|
});
|
|
|
|
|
if (!item) {
|
|
|
|
|
throw buildNotFoundError();
|
|
|
|
|
}
|
|
|
|
|
if (item.status !== "archived") {
|
|
|
|
|
throw buildContractRegistryValidationError({ status: "correction_requires_archived" }, 409);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const claimed = await tx.contractRegistry.updateMany({
|
|
|
|
|
where: { id: item.id, status: "archived", updated_at: item.updated_at },
|
|
|
|
|
data: { updated_at: new Date() },
|
|
|
|
|
});
|
|
|
|
|
if (claimed.count !== 1) {
|
|
|
|
|
throw buildContractRegistryValidationError({ status: "correction_state_conflict" }, 409);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_CORRECTION_APPENDED", contractId, {
|
|
|
|
|
summary: body.summary,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
return sendData(reply, request, { accepted: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fastify.post("/contracts-registry/:id/renew", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
|
|
|
await ensureFeatureEnabled(fastify, request);
|
|
|
|
|
if (!canManageContractRegistry(fastify.hasPermission, request.employee)) {
|
|
|
|
@@ -318,6 +658,7 @@ async function contractsRegistryRoutes(fastify) {
|
|
|
|
|
if (!original) {
|
|
|
|
|
throw buildNotFoundError();
|
|
|
|
|
}
|
|
|
|
|
assertTerminationInvariant(original);
|
|
|
|
|
|
|
|
|
|
const effectiveStatus = resolveContractRegistryEffectiveStatus(original, today);
|
|
|
|
|
if (!["active", "expired"].includes(effectiveStatus)) {
|
|
|
|
@@ -352,18 +693,14 @@ async function contractsRegistryRoutes(fastify) {
|
|
|
|
|
include: CONTRACT_REGISTRY_INCLUDE,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const archivedSource = await tx.contractRegistry.update({
|
|
|
|
|
where: { id: original.id },
|
|
|
|
|
data: { status: "archived" },
|
|
|
|
|
include: CONTRACT_REGISTRY_INCLUDE,
|
|
|
|
|
});
|
|
|
|
|
const archivedSource = await updateContractWithStateGuard(tx, original, { status: "archived" });
|
|
|
|
|
|
|
|
|
|
await writeAudit(tx, request, "CONTRACT_REGISTRY_RENEWED", renewed.id, {
|
|
|
|
|
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_RENEWED", renewed.id, {
|
|
|
|
|
sourceContractId: original.id,
|
|
|
|
|
sourceContractNo: original.contract_no,
|
|
|
|
|
renewedContractNo: renewed.contract_no,
|
|
|
|
|
});
|
|
|
|
|
await writeAudit(tx, request, "CONTRACT_REGISTRY_STATUS_CHANGED", archivedSource.id, {
|
|
|
|
|
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_STATUS_CHANGED", archivedSource.id, {
|
|
|
|
|
fromStatus: original.status,
|
|
|
|
|
toStatus: "archived",
|
|
|
|
|
reason: "renew",
|
|
|
|
|