feat(08-05-contract-detail-workspace): 完成合同详情与终止状态治理
这个提交包含在:
+15
@@ -0,0 +1,15 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "contract_registry"
|
||||
ADD COLUMN "terminated_at" TIMESTAMP(3),
|
||||
ADD COLUMN "terminated_reason" VARCHAR(1000),
|
||||
ADD COLUMN "terminated_by" UUID;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_contract_registry_terminator_terminated"
|
||||
ON "contract_registry"("terminated_by", "terminated_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "contract_registry"
|
||||
ADD CONSTRAINT "contract_registry_terminated_by_fkey"
|
||||
FOREIGN KEY ("terminated_by") REFERENCES "employees"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -716,6 +716,7 @@ model Employee {
|
||||
created_project_communication_logs ProjectCommunicationLog[] @relation("ProjectCommunicationLogCreator")
|
||||
completed_project_closure_checklists ProjectClosureChecklistItem[] @relation("ProjectClosureChecklistItemCompleter")
|
||||
owned_contract_registries ContractRegistry[] @relation("ContractRegistryOwner")
|
||||
terminated_contract_registries ContractRegistry[] @relation("ContractRegistryTerminator")
|
||||
owned_confidential_document_refs ConfidentialDocumentRef[] @relation("ConfidentialDocumentRefOwner")
|
||||
contracts EmployeeContract[]
|
||||
trainings EmployeeTraining[]
|
||||
@@ -2905,18 +2906,23 @@ model ContractRegistry {
|
||||
offline_archive_no String?
|
||||
owner_employee_id String @db.Uuid
|
||||
status String @default("active") @db.VarChar(32)
|
||||
terminated_at DateTime?
|
||||
terminated_reason String? @db.VarChar(1000)
|
||||
terminated_by String? @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
||||
owner Employee @relation("ContractRegistryOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict)
|
||||
terminator Employee? @relation("ContractRegistryTerminator", fields: [terminated_by], references: [id], onDelete: Restrict)
|
||||
document_refs ConfidentialDocumentRef[]
|
||||
|
||||
@@index([project_id, status], map: "idx_contract_registry_project_status")
|
||||
@@index([status, expiry_date], map: "idx_contract_registry_status_expiry")
|
||||
@@index([contract_type, status], map: "idx_contract_registry_type_status")
|
||||
@@index([owner_employee_id, created_at], map: "idx_contract_registry_owner_created")
|
||||
@@index([terminated_by, terminated_at], map: "idx_contract_registry_terminator_terminated")
|
||||
@@map("contract_registry")
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -411,8 +411,9 @@ function contractRegistryStatusLabel(value) {
|
||||
if (value === "draft") return "草稿";
|
||||
if (value === "active") return "生效中";
|
||||
if (value === "expired") return "已过期";
|
||||
if (value === "terminated") return "已终止";
|
||||
if (value === "archived") return "已归档";
|
||||
return value ?? "";
|
||||
return "未知状态";
|
||||
}
|
||||
|
||||
function paymentBatchEntityTypeLabel(value) {
|
||||
|
||||
@@ -7,6 +7,23 @@ const ExcelJS = require('exceljs');
|
||||
const { createPrismaClient } = require('../prismaClient');
|
||||
|
||||
const SUPPORTED_TYPES = ['projects', 'members', 'milestones', 'risks', 'contracts'];
|
||||
const CONTRACT_IMPORT_STATUS_VALUES = new Set(['draft', 'active', 'archived']);
|
||||
|
||||
function validateContractImportStatus(importedStatus, currentStatus = null) {
|
||||
if (!CONTRACT_IMPORT_STATUS_VALUES.has(importedStatus)) {
|
||||
return `status_not_importable:${importedStatus}`;
|
||||
}
|
||||
if (currentStatus === 'terminated') {
|
||||
return 'terminated_contract_is_final';
|
||||
}
|
||||
if (currentStatus === 'archived') {
|
||||
return 'archived_contract_requires_correction_api';
|
||||
}
|
||||
if (currentStatus && currentStatus !== importedStatus) {
|
||||
return 'status_change_requires_contract_api';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`用法:
|
||||
@@ -310,7 +327,7 @@ async function handleRisks({ prisma, rows, apply, summary }) {
|
||||
async function handleContracts({ prisma, rows, apply, summary }) {
|
||||
assertRequiredHeaders(rows, ['contract_no', 'counterparty_name', 'summary', 'archive_location', 'owner_employee_no']);
|
||||
const refs = await buildReferenceMaps(prisma);
|
||||
const existing = new Map((await prisma.contractRegistry.findMany({ select: { id: true, contract_no: true } })).map((item) => [item.contract_no, item]));
|
||||
const existing = new Map((await prisma.contractRegistry.findMany({ select: { id: true, contract_no: true, status: true } })).map((item) => [item.contract_no, item]));
|
||||
|
||||
for (const row of rows) {
|
||||
const data = row.data;
|
||||
@@ -325,6 +342,14 @@ async function handleContracts({ prisma, rows, apply, summary }) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const importedStatus = data.status || 'active';
|
||||
const current = existing.get(data.contract_no);
|
||||
const statusError = validateContractImportStatus(importedStatus, current?.status ?? null);
|
||||
if (statusError) {
|
||||
pushError(summary, row.rowNumber, statusError);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
contract_no: data.contract_no,
|
||||
project_id: project?.id ?? null,
|
||||
@@ -334,10 +359,9 @@ async function handleContracts({ prisma, rows, apply, summary }) {
|
||||
archive_location: data.archive_location,
|
||||
offline_archive_no: data.offline_archive_no || null,
|
||||
owner_employee_id: owner.id,
|
||||
status: data.status || 'active',
|
||||
status: importedStatus,
|
||||
};
|
||||
|
||||
const current = existing.get(data.contract_no);
|
||||
if (!current) summary.createCount += 1;
|
||||
else summary.updateCount += 1;
|
||||
|
||||
@@ -400,7 +424,11 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.message ?? error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error?.message ?? error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { validateContractImportStatus };
|
||||
|
||||
@@ -4,7 +4,7 @@ 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_PERSISTED_STATUS_VALUES = ["draft", "active", "terminated", "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}$/);
|
||||
|
||||
@@ -115,6 +115,10 @@ function buildEffectiveStatusWhere(status, today) {
|
||||
return { status: "archived" };
|
||||
}
|
||||
|
||||
if (status === "terminated") {
|
||||
return { status: "terminated" };
|
||||
}
|
||||
|
||||
if (status === "expired") {
|
||||
return {
|
||||
status: "active",
|
||||
@@ -221,6 +225,7 @@ module.exports = {
|
||||
CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES,
|
||||
CONTRACT_REGISTRY_TYPE_VALUES,
|
||||
buildContractRegistryExpiringWhere,
|
||||
buildContractRegistryScopeWhere,
|
||||
buildContractRegistryValidationError,
|
||||
buildContractRegistryWhere,
|
||||
canManageContractRegistry,
|
||||
|
||||
@@ -45,6 +45,9 @@ function buildContractRow(overrides = {}) {
|
||||
offline_archive_no: "OFF-001",
|
||||
owner_employee_id: OWNER_ID,
|
||||
status: "active",
|
||||
terminated_at: null,
|
||||
terminated_reason: null,
|
||||
terminated_by: null,
|
||||
created_at: new Date("2026-03-01T08:00:00.000Z"),
|
||||
updated_at: new Date("2026-04-01T08:00:00.000Z"),
|
||||
owner: {
|
||||
@@ -57,6 +60,8 @@ function buildContractRow(overrides = {}) {
|
||||
project_no: "PRJ-001",
|
||||
name: "试点项目",
|
||||
},
|
||||
terminator: null,
|
||||
document_refs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -79,16 +84,34 @@ function cloneRow(row) {
|
||||
...row,
|
||||
owner: row.owner ? { ...row.owner } : null,
|
||||
project: row.project ? { ...row.project } : null,
|
||||
terminator: row.terminator ? { ...row.terminator } : null,
|
||||
document_refs: (row.document_refs ?? []).map((item) => ({
|
||||
...item,
|
||||
owner: item.owner ? { ...item.owner } : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatefulPrisma(initialContracts) {
|
||||
function findIdInWhere(where) {
|
||||
if (!where || typeof where !== "object") return null;
|
||||
if (typeof where.id === "string") return where.id;
|
||||
for (const condition of where.AND ?? []) {
|
||||
const id = findIdInWhere(condition);
|
||||
if (id) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildStatefulPrisma(initialContracts, options = {}) {
|
||||
const state = {
|
||||
contracts: initialContracts.map((item) => cloneRow(item)),
|
||||
audits: [],
|
||||
};
|
||||
|
||||
const auditCreate = vi.fn(async ({ data }) => {
|
||||
if (options.failAuditAction === data.action) {
|
||||
throw new Error("audit unavailable");
|
||||
}
|
||||
state.audits.push(data);
|
||||
return data;
|
||||
});
|
||||
@@ -123,6 +146,13 @@ function buildStatefulPrisma(initialContracts) {
|
||||
}
|
||||
return cloneRow(row);
|
||||
}),
|
||||
findFirst: vi.fn(async ({ where, select, include }) => {
|
||||
const id = findIdInWhere(where);
|
||||
const row = id ? state.contracts.find((item) => item.id === id) : state.contracts[0];
|
||||
if (!row) return null;
|
||||
if (select) return mapSelectedRow(row, select);
|
||||
return include ? cloneRow(row) : cloneRow(row);
|
||||
}),
|
||||
create: vi.fn(async ({ data, include }) => {
|
||||
const created = buildContractRow({
|
||||
id: `66666666-6666-4666-8666-${String(state.contracts.length + 1).padStart(12, "0")}`,
|
||||
@@ -141,6 +171,9 @@ function buildStatefulPrisma(initialContracts) {
|
||||
offline_archive_no: data.offline_archive_no,
|
||||
owner_employee_id: data.owner_employee_id,
|
||||
status: data.status,
|
||||
terminated_at: data.terminated_at ?? null,
|
||||
terminated_reason: data.terminated_reason ?? null,
|
||||
terminated_by: data.terminated_by ?? null,
|
||||
});
|
||||
state.contracts.push(created);
|
||||
return include ? cloneRow(created) : created;
|
||||
@@ -157,6 +190,25 @@ function buildStatefulPrisma(initialContracts) {
|
||||
};
|
||||
return include ? cloneRow(state.contracts[index]) : state.contracts[index];
|
||||
}),
|
||||
updateMany: vi.fn(async ({ where, data }) => {
|
||||
const sameValue = (current, expected) =>
|
||||
current instanceof Date && expected instanceof Date
|
||||
? current.getTime() === expected.getTime()
|
||||
: current === expected;
|
||||
const index = state.contracts.findIndex((item) =>
|
||||
Object.entries(where).every(([key, value]) => sameValue(item[key], value)),
|
||||
);
|
||||
if (index < 0) return { count: 0 };
|
||||
state.contracts[index] = {
|
||||
...state.contracts[index],
|
||||
...data,
|
||||
updated_at: new Date("2026-04-04T12:00:00.000Z"),
|
||||
terminator: data.terminated_by
|
||||
? { id: data.terminated_by, employee_no: "E-ACTOR", name: "Actor" }
|
||||
: state.contracts[index].terminator,
|
||||
};
|
||||
return { count: 1 };
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -167,13 +219,30 @@ function buildStatefulPrisma(initialContracts) {
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
findMany: vi.fn(async () =>
|
||||
state.audits.map((item, index) => ({
|
||||
id: `audit-${index + 1}`,
|
||||
...item,
|
||||
created_at: new Date(`2026-04-04T10:0${index}:00.000Z`),
|
||||
actor: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
||||
})),
|
||||
),
|
||||
},
|
||||
contractRegistry,
|
||||
$transaction: async (callback) =>
|
||||
callback({
|
||||
auditLog: { create: auditCreate },
|
||||
contractRegistry,
|
||||
}),
|
||||
$transaction: async (callback) => {
|
||||
const contractsBefore = state.contracts.map((item) => cloneRow(item));
|
||||
const auditsBefore = [...state.audits];
|
||||
try {
|
||||
return await callback({
|
||||
auditLog: { create: auditCreate },
|
||||
contractRegistry,
|
||||
});
|
||||
} catch (error) {
|
||||
state.contracts = contractsBefore;
|
||||
state.audits = auditsBefore;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
auditCreate,
|
||||
};
|
||||
@@ -195,12 +264,15 @@ async function buildApp(options = {}) {
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
contractRegistry: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(buildContractRow()),
|
||||
update: vi.fn().mockResolvedValue(buildContractRow()),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
};
|
||||
fallback.$transaction = async (callback) => callback(fallback);
|
||||
@@ -274,6 +346,10 @@ describe("contracts registry routes", () => {
|
||||
amount: "100000.00",
|
||||
}),
|
||||
);
|
||||
expect(payload.data.items[0]).not.toHaveProperty("terminatedAt");
|
||||
expect(payload.data.items[0]).not.toHaveProperty("terminatedReason");
|
||||
expect(payload.data.items[0]).not.toHaveProperty("terminatedBy");
|
||||
expect(payload.data.items[0]).not.toHaveProperty("terminator");
|
||||
expect(contractFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
@@ -297,6 +373,43 @@ describe("contracts registry routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps terminated contracts terminal even when their expiry date is in the past", async () => {
|
||||
const contractFindMany = vi.fn().mockResolvedValue([
|
||||
buildContractRow({
|
||||
status: "terminated",
|
||||
expiry_date: new Date("2026-03-31T00:00:00.000Z"),
|
||||
terminated_at: new Date("2026-03-20T08:00:00.000Z"),
|
||||
terminated_reason: "双方确认提前终止合同并保留原编号",
|
||||
terminated_by: ACTOR_ID,
|
||||
terminator: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
||||
}),
|
||||
]);
|
||||
const { app } = await buildApp({
|
||||
prisma: {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
contractRegistry: {
|
||||
findMany: contractFindMany,
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/contracts-registry" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0]).toEqual(expect.objectContaining({
|
||||
status: "terminated",
|
||||
persistedStatus: "terminated",
|
||||
}));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects invalid expiry date range", async () => {
|
||||
const { app } = await buildApp();
|
||||
const response = await app.inject({
|
||||
@@ -379,6 +492,316 @@ describe("contracts registry routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns a scoped contract detail with safe document references and history", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({
|
||||
id: contractId,
|
||||
document_refs: [
|
||||
{
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
title: "线下涉密资料索引",
|
||||
document_no: "DOC-001",
|
||||
confidentiality_level: "restricted",
|
||||
storage_type: "offline_archive",
|
||||
storage_path: "/sensitive/full/path",
|
||||
offline_archive_no: "OFF-DOC-001",
|
||||
access_rule_json: { raw: true },
|
||||
created_at: new Date("2026-04-01T08:00:00.000Z"),
|
||||
updated_at: new Date("2026-04-02T08:00:00.000Z"),
|
||||
owner: { id: OWNER_ID, employee_no: "E001", name: "Owner" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
stateful.state.audits.push({
|
||||
request_id: "request-1",
|
||||
actor_id: ACTOR_ID,
|
||||
action: "CONTRACT_REGISTRY_UPDATED",
|
||||
target_type: "contract_registry",
|
||||
target_id: contractId,
|
||||
extra: { fields: ["summary"], rawPayload: "不得返回" },
|
||||
});
|
||||
const { app } = await buildApp({
|
||||
prisma: stateful.prisma,
|
||||
actor: {
|
||||
id: OWNER_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["project.view_own"],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/contracts-registry/${contractId}` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const item = response.json().data.item;
|
||||
expect(item.documentRefs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "线下涉密资料索引",
|
||||
documentNo: "DOC-001",
|
||||
storageType: "offline_archive",
|
||||
offlineArchiveNo: "OFF-DOC-001",
|
||||
}),
|
||||
);
|
||||
expect(item.documentRefs[0]).not.toHaveProperty("storagePath");
|
||||
expect(item.documentRefs[0]).not.toHaveProperty("accessRuleJson");
|
||||
expect(item.history[0]).toEqual(expect.objectContaining({ summary: "更新合同信息" }));
|
||||
expect(item.history[0]).not.toHaveProperty("extra");
|
||||
expect(stateful.prisma.contractRegistry.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ AND: expect.arrayContaining([{ id: contractId }]) }),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("terminates an active or expired contract with complete fields and required audit", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "active" })]);
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/terminate`,
|
||||
payload: { reason: "客户项目已取消,双方确认不再继续履行" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "terminated",
|
||||
persistedStatus: "terminated",
|
||||
terminatedReason: "客户项目已取消,双方确认不再继续履行",
|
||||
terminatedBy: ACTOR_ID,
|
||||
terminator: expect.objectContaining({ id: ACTOR_ID }),
|
||||
}),
|
||||
);
|
||||
const stored = stateful.state.contracts.find((item) => item.id === contractId);
|
||||
expect(stored).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "terminated",
|
||||
terminated_reason: "客户项目已取消,双方确认不再继续履行",
|
||||
terminated_by: ACTOR_ID,
|
||||
}),
|
||||
);
|
||||
expect(stored.terminated_at).toBeInstanceOf(Date);
|
||||
expect(stateful.state.audits.map((item) => item.action)).toContain("CONTRACT_REGISTRY_TERMINATED");
|
||||
const terminationAudit = stateful.state.audits.find((item) => item.action === "CONTRACT_REGISTRY_TERMINATED");
|
||||
expect(terminationAudit?.extra).toEqual(expect.objectContaining({
|
||||
reasonSummary: "客户项目已取消,双方确认不再继续履行",
|
||||
}));
|
||||
expect(terminationAudit?.extra).not.toHaveProperty("reason");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back all termination fields when required audit creation fails", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
||||
failAuditAction: "CONTRACT_REGISTRY_TERMINATED",
|
||||
});
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/terminate`,
|
||||
payload: { reason: "客户项目已取消,双方确认不再继续履行" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(stateful.state.contracts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "active",
|
||||
terminated_at: null,
|
||||
terminated_reason: null,
|
||||
terminated_by: null,
|
||||
}),
|
||||
);
|
||||
expect(stateful.state.audits).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects terminated through general PATCH and keeps terminated contracts final", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const terminated = buildContractRow({
|
||||
id: contractId,
|
||||
status: "terminated",
|
||||
terminated_at: new Date("2026-04-04T08:30:00.000Z"),
|
||||
terminated_reason: "双方确认提前终止合同并保留原编号",
|
||||
terminated_by: ACTOR_ID,
|
||||
terminator: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
||||
});
|
||||
const stateful = buildStatefulPrisma([terminated]);
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const bypassResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { status: "terminated" },
|
||||
});
|
||||
expect(bypassResponse.statusCode).toBe(400);
|
||||
|
||||
const editResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { summary: "试图覆盖已终止合同" },
|
||||
});
|
||||
expect(editResponse.statusCode).toBe(409);
|
||||
expect(editResponse.json().error.details).toEqual(expect.objectContaining({ status: "terminated_is_final" }));
|
||||
|
||||
const renewResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/renew`,
|
||||
});
|
||||
expect(renewResponse.statusCode).toBe(409);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("locks archived fields and appends corrections without overwriting the contract", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "archived" })]);
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const editResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { archiveLocation: "新位置" },
|
||||
});
|
||||
expect(editResponse.statusCode).toBe(409);
|
||||
expect(editResponse.json().error.details).toEqual(expect.objectContaining({ status: "archived_fields_locked" }));
|
||||
|
||||
const correctionResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/corrections`,
|
||||
payload: { summary: "原归档位置登记有误,实际以线下档案号为准" },
|
||||
});
|
||||
expect(correctionResponse.statusCode).toBe(200);
|
||||
expect(stateful.state.contracts[0].archive_location).toBe("/archive/contracts/001");
|
||||
expect(stateful.state.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
action: "CONTRACT_REGISTRY_CORRECTION_APPENDED",
|
||||
extra: expect.objectContaining({ summary: "原归档位置登记有误,实际以线下档案号为准" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects a stale archived correction when the contract changed concurrently", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "archived" })]);
|
||||
stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/corrections`,
|
||||
payload: { summary: "原归档位置登记有误,实际以线下档案号为准" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({ status: "correction_state_conflict" }));
|
||||
expect(stateful.state.audits).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back an archive status change when required audit creation fails", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
||||
failAuditAction: "CONTRACT_REGISTRY_STATUS_CHANGED",
|
||||
});
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { status: "archived" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(stateful.state.contracts[0].status).toBe("active");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back an archive status change when its history audit fails", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
||||
failAuditAction: "CONTRACT_REGISTRY_UPDATED",
|
||||
});
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { status: "archived" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(stateful.state.contracts[0].status).toBe("active");
|
||||
expect(stateful.state.audits).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed when a contract row violates the termination field invariant", async () => {
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({
|
||||
terminated_reason: "存在终止原因但状态尚未切换",
|
||||
}),
|
||||
]);
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/contracts-registry" });
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error).toEqual(expect.objectContaining({ code: "SYS_001" }));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects a stale general update instead of overwriting a concurrent terminal transition", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })]);
|
||||
stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { summary: "并发状态变化后不得覆盖合同" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual(
|
||||
expect.objectContaining({ status: "contract_state_conflict" }),
|
||||
);
|
||||
expect(stateful.state.contracts[0].summary).toBe("合同摘要");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uses the same non-enumerating 404 for an unavailable contract detail", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: ["project.view_own"] },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/contracts-registry/55555555-5555-4555-8555-555555555551",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toEqual(expect.objectContaining({ code: "SYS_404", message: "Not Found" }));
|
||||
expect(response.body).not.toContain("合同摘要");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("renews an active contract into the next draft revision and archives the source", async () => {
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({
|
||||
@@ -425,6 +848,28 @@ describe("contracts registry routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back the renewal draft and source archive when renewal audit fails", async () => {
|
||||
const sourceId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({ id: sourceId, contract_no: "HT-2026-001", status: "active" }),
|
||||
], {
|
||||
failAuditAction: "CONTRACT_REGISTRY_RENEWED",
|
||||
});
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${sourceId}/renew`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(stateful.state.contracts).toHaveLength(1);
|
||||
expect(stateful.state.contracts[0]).toEqual(expect.objectContaining({ id: sourceId, status: "active" }));
|
||||
expect(stateful.state.audits).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects renewing a draft contract", async () => {
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({
|
||||
|
||||
@@ -203,4 +203,30 @@ describe("contracts registry export route", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("exports terminated contracts with a Chinese status label", async () => {
|
||||
const { app } = await buildApp({
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
buildContractRow({
|
||||
status: "terminated",
|
||||
expiry_date: new Date("2026-03-31T00:00:00.000Z"),
|
||||
}),
|
||||
])
|
||||
.mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/exports/contracts-registry",
|
||||
payload: { format: "csv", status: "terminated" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain("已终止");
|
||||
expect(response.body).not.toContain(",terminated,");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const { validateContractImportStatus } = require("../../../src/scripts/import-project-ledgers");
|
||||
|
||||
describe("contract ledger import status guard", () => {
|
||||
it("allows only non-terminal persisted statuses", () => {
|
||||
expect(validateContractImportStatus("draft")).toBeNull();
|
||||
expect(validateContractImportStatus("active")).toBeNull();
|
||||
expect(validateContractImportStatus("archived")).toBeNull();
|
||||
expect(validateContractImportStatus("expired")).toBe("status_not_importable:expired");
|
||||
expect(validateContractImportStatus("terminated")).toBe("status_not_importable:terminated");
|
||||
});
|
||||
|
||||
it("does not overwrite an already terminated contract", () => {
|
||||
expect(validateContractImportStatus("active", "terminated")).toBe("terminated_contract_is_final");
|
||||
expect(validateContractImportStatus("archived", "terminated")).toBe("terminated_contract_is_final");
|
||||
});
|
||||
|
||||
it("does not perform an unaudited status transition for an existing row", () => {
|
||||
expect(validateContractImportStatus("archived", "active")).toBe("status_change_requires_contract_api");
|
||||
expect(validateContractImportStatus("active", "active")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not overwrite ordinary fields on an archived row", () => {
|
||||
expect(validateContractImportStatus("archived", "archived")).toBe("archived_contract_requires_correction_api");
|
||||
expect(validateContractImportStatus("active", "archived")).toBe("archived_contract_requires_correction_api");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户