feat(08-05-contract-detail-workspace): 完成合同详情与终止状态治理

这个提交包含在:
2026-08-08 02:02:52 +08:00
父节点 7546d372d6
当前提交 21851774dd
共修改 25 个文件,包含 2516 行新增和 1021 行删除
@@ -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");
});
});