Commit 20a651b9 authored by drigle's avatar drigle

feat: implement immutable problem rectification archives

parent 439b3b69
......@@ -2,6 +2,7 @@ import FormDatePicker from '@/components/FormDatePicker';
import ResourceFilterMultiSelect from '@/components/ResourceFilterMultiSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import {
Archive,
CalendarDaysIcon,
ChevronRightIcon,
Close,
......@@ -71,6 +72,7 @@ import {
FLOW_MODE_BATCH,
FLOW_MODE_DELAY_DEPARTMENT,
FLOW_MODE_DEPARTMENT,
archiveProblemRectificationRecords,
buildDepartmentAssignmentsFromRecord,
buildDepartmentDelayRequestItems,
buildDelayRequestItemsBatchUpdateData,
......@@ -801,6 +803,8 @@ export default function ProblemRectificationListPage() {
const [delayDetailRecord, setDelayDetailRecord] = useState(null);
const [cloneTargetRecord, setCloneTargetRecord] = useState(null);
const [deleteTargetRecords, setDeleteTargetRecords] = useState([]);
const [archiveTargetRecords, setArchiveTargetRecords] = useState([]);
const [archiveError, setArchiveError] = useState('');
const [formValues, setFormValues] = useState({});
const [fillerUsers, setFillerUsers] = useState([]);
const [auditorUsers, setAuditorUsers] = useState([]);
......@@ -1213,6 +1217,46 @@ export default function ProblemRectificationListPage() {
}
};
const handleArchive = (targetRecords) => {
if (!targetRecords || targetRecords.length === 0) {
setError('请先选择需要归档的记录。');
return;
}
setError('');
setArchiveError('');
setArchiveTargetRecords(targetRecords);
};
const confirmArchive = async () => {
const targetRecords = archiveTargetRecords;
if (targetRecords.length === 0) {
return;
}
const recordIds = targetRecords.map((record) => record.id).filter(Boolean);
if (recordIds.length !== targetRecords.length) {
setError('选中记录缺少可归档的 ID,请刷新列表后重试。');
return;
}
setActionLoading(true);
setError('');
setArchiveError('');
setSuccessMessage('');
try {
const result = await archiveProblemRectificationRecords(recordIds);
setArchiveTargetRecords([]);
setSelectedIds([]);
setSuccessMessage(`归档成功,已生成“${result.batchTitle}”及 ${result.count} 条独立快照。`);
} catch (requestError) {
setArchiveError(requestError?.message || '问题整改归档失败,请稍后重试。');
} finally {
setActionLoading(false);
}
};
const handleClone = async () => {
if (!cloneTargetRecord) {
return;
......@@ -1887,6 +1931,14 @@ export default function ProblemRectificationListPage() {
actionLoading,
onClick: openDelayPushModal,
},
{
key: 'archive-selected',
label: '一键归档',
icon: Archive,
variant: 'ghost',
disabled: () => selectedIds.length === 0 || actionLoading,
onClick: () => handleArchive(selectedRecords),
},
{
key: 'delete-selected',
label: '删除选中',
......@@ -2558,6 +2610,60 @@ export default function ProblemRectificationListPage() {
</Alert>
</XinyuanModal>
) : null}
{archiveTargetRecords.length > 0 ? (
<XinyuanModal
isOpen={archiveTargetRecords.length > 0}
onOpenChange={(isOpen) => {
if (!isOpen && !actionLoading) {
setArchiveError('');
setArchiveTargetRecords([]);
}
}}
size="md"
title="确认一键归档"
description={`将为选中的 ${archiveTargetRecords.length} 条问题生成独立归档快照,并创建一个归档批次。`}
footer={
<>
<HeroButton
isDisabled={actionLoading}
size="sm"
variant="secondary"
onPress={() => {
setArchiveError('');
setArchiveTargetRecords([]);
}}
>
取消
</HeroButton>
<HeroButton
isDisabled={actionLoading}
isPending={actionLoading}
size="sm"
variant="primary"
onPress={confirmArchive}
>
{actionLoading ? '归档中...' : '确认归档'}
</HeroButton>
</>
}
>
{archiveError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{archiveError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Alert status="warning">
<Alert.Content>
<Alert.Description>
归档不会锁定、删除或修改原问题;原问题后续变化不会更新本次快照。
</Alert.Description>
</Alert.Content>
</Alert>
</XinyuanModal>
) : null}
</>
);
}
......@@ -501,7 +501,8 @@
- 功能入口:`/problem_rectification` 主列表,用户勾选一条或多条问题后点击“一键归档”
- 数据对象:归档明细写入 `problem_rectification_archive_records`;归档批次写入 `issue_rectification_archive`
- 发起校验:必须至少选择 1 条问题;归档动作生成快照,不改变原问题记录状态,也不自动锁定、删除或结束流程
- 明细转换:按 `problem_rectification_archive_records` 字段结构从主记录 `metadata` 映射归档字段;`responsible_department` 转为逗号拼接文本;`fill_in_the_people`、`review_people` 只保存用户 `_id[]`;`rectification_proof_materials` 作为文件对象保留;日期字段继续保存 ISO 8601 字符串
- 明细转换:按 `problem_rectification_archive_records` 的 51 个字段从主记录 `metadata` 生成独立快照;`responsible_department` 转为逗号拼接文本;`fill_in_the_people`、`review_people` 保持 `user` 类型并只保存用户 `_id[]`;`rectification_proof_materials` 作为文件对象保留;日期字段继续保存 ISO 8601 字符串
- 嵌套快照:`department_rectification_items` 与 `problem_rectification_delay_request_items` 在归档明细中均使用多值 `object`,归档时深拷贝整组对象,不使用 `subject` 或 `reference` 关联原问题中的嵌套数据
- 批次写入:归档明细导入成功后创建 1 条 `issue_rectification_archive` 批次记录,标题格式为“问题整改归档 YYYY-MM-DD”,并在 `metadata.reference_records` 中保存本批次所有归档明细 `_id`
- 归档查看:`/issue_rectification_archive` 展示归档批次列表;批次详情通过 `reference_records` 追溯归档明细数量和具体快照内容
- 异常限制:归档是一次性历史快照,原问题记录后续编辑不会反向更新归档明细;若归档明细导入失败,不创建批次;若批次创建失败,需要提示用户本次归档未完整闭环,避免出现孤立快照无人可查
......@@ -714,7 +715,7 @@
1. 在问题整改列表中勾选一条或多条记录
2. 点击“一键归档”
3. 前端按 `problem_rectification_archive_records` 的字段结构转换当前问题数据,调用 `/record/import` 导入归档明细快照
3. 确认弹窗说明归档不修改原问题;用户确认后,前端重新读取所选问题详情及 `problem_rectification_archive_records` 的实时字段定义,仅按归档对象字段白名单转换当前问题数据,并调用 `/record/import` 导入归档明细快照
4. 归档明细导入成功后,创建 1 条 `issue_rectification_archive` 批次记录,写入 `metadata.reference_records = 归档明细 _id[]`
5. 归档列表页展示归档批次,批次详情通过 `reference_records` 追溯批次下明细
6. 归档动作不修改原 `problem_rectification` 记录,不自动锁定,不自动删除,也不改变正在进行中的推送或延期流程
......@@ -724,8 +725,10 @@
- 原主表中的多值选择 `responsible_department` 在旧实现中被转成逗号拼接文本
- `fill_in_the_people`、`review_people` 继续保存为用户 `_id[]`
- `rectification_proof_materials` 按线上字段的单值或多值形态保留文件对象或文件对象数组
- 主记录的治理字段、锁定状态、部门整改明细和延期申请明细均写入 `problem_rectification_archive_records`;两组嵌套明细以多值 `object` 深拷贝保存,不建立到原问题嵌套数据的引用
- 归档是快照,后续原问题记录再变化,不应反向污染归档结果
- 归档明细导入失败时不创建归档批次;归档批次创建失败时应提示用户归档未完整完成,避免出现无法从批次入口追溯的孤立明细
- 归档期间按钮进入加载状态并禁止重复提交;读取原记录条数、导入返回的归档明细 ID 数量与用户选择数必须一致,否则中止创建批次并提示刷新或联系管理员处理
---
......
function toArray(value) {
if (value === undefined || value === null || value === '') {
return [];
}
return Array.isArray(value) ? value : [value];
}
function normalizeText(value) {
return value === undefined || value === null ? '' : String(value).trim();
}
function cloneArchiveValue(value) {
if (value === undefined || value === null || typeof value !== 'object') {
return value;
}
return JSON.parse(JSON.stringify(value));
}
export function normalizeArchiveRecordId(value) {
if (!value) {
return '';
}
if (typeof value === 'object') {
return normalizeText(value._id || value.id || value.name);
}
return normalizeText(value);
}
function normalizeArchiveDate(value, fieldName) {
if (!value) {
return value;
}
const parsedDate = new Date(value);
if (Number.isNaN(parsedDate.getTime())) {
throw new Error(`归档字段“${fieldName}”不是有效日期,请修正后重试。`);
}
return parsedDate.toISOString();
}
function normalizeArchiveFieldValue(value, field) {
if (value === undefined) {
return undefined;
}
if (field.name === 'responsible_department') {
return toArray(value)
.map((item) => normalizeText(item))
.filter(Boolean)
.join(',');
}
if (field.type === 'user') {
const userIds = toArray(value).map(normalizeArchiveRecordId).filter(Boolean);
return field.multiple ? userIds : userIds[0] || null;
}
if (field.type === 'date') {
if (field.multiple) {
return toArray(value).map((item) => normalizeArchiveDate(item, field.label || field.name));
}
return normalizeArchiveDate(value, field.label || field.name);
}
if (field.type === 'object' && typeof value === 'string') {
try {
return cloneArchiveValue(JSON.parse(value));
} catch {
throw new Error(`归档字段“${field.label || field.name}”不是有效 JSON,请修正后重试。`);
}
}
return cloneArchiveValue(value);
}
export function buildProblemRectificationArchiveSnapshot(record, archiveSubject) {
const metadata = record?.metadata || {};
const archiveMetadata = {};
toArray(archiveSubject?.fields).forEach((field) => {
if (!field?.name) {
return;
}
const value = normalizeArchiveFieldValue(metadata[field.name], field);
if (value !== undefined) {
archiveMetadata[field.name] = value;
}
});
return {
title: record?.title || metadata.project_name || metadata.problem_brief || undefined,
metadata: archiveMetadata,
};
}
import dayjs from 'dayjs';
import {
countRecords,
createRecord,
getSubjectView,
getSubjectViewDetail,
importRecords,
loadSubject,
queryRecords,
} from '@/services/DataService';
......@@ -29,8 +31,16 @@ import {
sortRecordsByView,
getViewRestrictionDefaults,
} from '@/utils/subjectView';
import {
buildProblemRectificationArchiveSnapshot,
normalizeArchiveRecordId,
} from './problemRectificationArchiveSnapshot.mjs';
export { buildProblemRectificationArchiveSnapshot };
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
export const ISSUE_RECTIFICATION_ARCHIVE_SUBJECT = 'issue_rectification_archive';
export const PROBLEM_RECTIFICATION_ARCHIVE_RECORDS_SUBJECT = 'problem_rectification_archive_records';
export const PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE = 20;
export const DEPARTMENT_RECTIFICATION_FIELD = 'department_rectification_items';
......@@ -97,6 +107,73 @@ const FOCUS_FILTER_OPTIONS = [
{ label: '制度/系统整改类', value: 'policy_or_system' },
];
function extractImportedRecordIds(importResult) {
const insertedIds = importResult?.insertedIds || importResult?.data?.insertedIds || {};
return Object.values(insertedIds).map(normalizeArchiveRecordId).filter(Boolean);
}
export async function archiveProblemRectificationRecords(recordIds = []) {
const normalizedRecordIds = Array.from(
new Set(recordIds.map(normalizeArchiveRecordId).filter(Boolean)),
);
if (normalizedRecordIds.length === 0) {
throw new Error('请先选择需要归档的问题整改记录。');
}
const [sourceResult, archiveSubject] = await Promise.all([
queryRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
page: 0,
page_size: normalizedRecordIds.length,
filter: { _id: { $in: normalizedRecordIds } },
mode: 'full',
}),
loadSubject(PROBLEM_RECTIFICATION_ARCHIVE_RECORDS_SUBJECT),
]);
const sourceRecords = sourceResult?.records || [];
if (sourceRecords.length !== normalizedRecordIds.length) {
throw new Error(
`归档前只读取到 ${sourceRecords.length}/${normalizedRecordIds.length} 条原记录,请刷新列表后重试。`,
);
}
const snapshots = sourceRecords.map((record) =>
buildProblemRectificationArchiveSnapshot(record, archiveSubject),
);
const importResult = await importRecords(
PROBLEM_RECTIFICATION_ARCHIVE_RECORDS_SUBJECT,
snapshots,
);
const archiveRecordIds = extractImportedRecordIds(importResult);
if (archiveRecordIds.length !== snapshots.length) {
throw new Error(
`归档明细写入不完整:预期 ${snapshots.length} 条,实际返回 ${archiveRecordIds.length} 条,未创建归档批次。`,
);
}
const batchTitle = `问题整改归档 ${dayjs().format('YYYY-MM-DD')}`;
try {
const batchRecord = await createRecord({
subject: ISSUE_RECTIFICATION_ARCHIVE_SUBJECT,
title: batchTitle,
metadata: {
reference_records: archiveRecordIds,
},
});
return {
batchRecord,
batchTitle,
archiveRecordIds,
count: archiveRecordIds.length,
};
} catch (error) {
throw new Error(
`已生成 ${archiveRecordIds.length} 条归档快照,但归档批次创建失败;请联系管理员处理孤立快照。${error?.message ? ` ${error.message}` : ''}`,
);
}
}
function toArray(value) {
if (!value) {
return [];
......
This diff is collapsed.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildProblemRectificationArchiveSnapshot,
} from '../src/pages/problem_rectification/problemRectificationArchiveSnapshot.mjs';
const ARCHIVE_SUBJECT = {
fields: [
{ name: 'project_name', type: 'text', multiple: false },
{ name: 'responsible_department', type: 'text', multiple: false },
{ name: 'fill_in_the_people', type: 'user', multiple: true },
{ name: 'the_time_of_rectification', label: '预计整改完成时间', type: 'date', multiple: false },
{ name: 'is_locked', type: 'boolean', multiple: false },
{ name: 'department_rectification_items', type: 'object', multiple: true },
],
};
test('builds an archive-field whitelist snapshot with normalized protocol values', () => {
const source = {
title: '问题 A',
metadata: {
project_name: '检查项目',
responsible_department: ['法律合规部', '审计部'],
fill_in_the_people: [{ _id: 'user-1', display_name: '甲' }, 'user-2'],
the_time_of_rectification: '2026-09-28',
is_locked: true,
department_rectification_items: [{ item_id: 'item-1', status: '已整改' }],
field_not_in_archive_subject: '不会写入',
},
};
const snapshot = buildProblemRectificationArchiveSnapshot(source, ARCHIVE_SUBJECT);
assert.equal(snapshot.title, '问题 A');
assert.deepEqual(snapshot.metadata, {
project_name: '检查项目',
responsible_department: '法律合规部,审计部',
fill_in_the_people: ['user-1', 'user-2'],
the_time_of_rectification: '2026-09-28T00:00:00.000Z',
is_locked: true,
department_rectification_items: [{ item_id: 'item-1', status: '已整改' }],
});
});
test('deep-copies nested archive objects so later source edits cannot change the snapshot', () => {
const source = {
metadata: {
project_name: '检查项目',
department_rectification_items: [
{ item_id: 'item-1', proof: { name: '归档前材料.pdf' } },
],
},
};
const snapshot = buildProblemRectificationArchiveSnapshot(source, ARCHIVE_SUBJECT);
source.metadata.department_rectification_items[0].proof.name = '原数据后续修改.pdf';
assert.equal(
snapshot.metadata.department_rectification_items[0].proof.name,
'归档前材料.pdf',
);
});
test('normalizes legacy JSON strings into independent object snapshots', () => {
const snapshot = buildProblemRectificationArchiveSnapshot(
{
metadata: {
department_rectification_items: '[{"item_id":"legacy-item"}]',
},
},
ARCHIVE_SUBJECT,
);
assert.deepEqual(snapshot.metadata.department_rectification_items, [
{ item_id: 'legacy-item' },
]);
});
test('rejects invalid configured date values instead of writing a non-ISO archive value', () => {
assert.throws(
() => buildProblemRectificationArchiveSnapshot(
{ metadata: { the_time_of_rectification: '不是日期' } },
ARCHIVE_SUBJECT,
),
/预计整改完成时间.*不是有效日期/,
);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment