Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
P
problem_rectification
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
problem_rectification
Commits
20a651b9
Commit
20a651b9
authored
Sep 28, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement immutable problem rectification archives
parent
439b3b69
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
374 additions
and
2 deletions
+374
-2
index.jsx
src/pages/problem_rectification/list/index.jsx
+106
-0
problem-rectification.business-flow.md
...blem_rectification/problem-rectification.business-flow.md
+5
-2
problemRectificationArchiveSnapshot.mjs
...lem_rectification/problemRectificationArchiveSnapshot.mjs
+99
-0
problemRectificationListService.js
.../problem_rectification/problemRectificationListService.js
+77
-0
subjectsDefinition.md
src/services/subjectsDefinition.md
+0
-0
problemRectificationArchiveSnapshot.test.mjs
tests/problemRectificationArchiveSnapshot.test.mjs
+87
-0
No files found.
src/pages/problem_rectification/list/index.jsx
View file @
20a651b9
...
...
@@ -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}
</>
);
}
src/pages/problem_rectification/problem-rectification.business-flow.md
View file @
20a651b9
...
...
@@ -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 数量与用户选择数必须一致,否则中止创建批次并提示刷新或联系管理员处理
---
...
...
src/pages/problem_rectification/problemRectificationArchiveSnapshot.mjs
0 → 100644
View file @
20a651b9
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
,
};
}
src/pages/problem_rectification/problemRectificationListService.js
View file @
20a651b9
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 [];
...
...
src/services/subjectsDefinition.md
View file @
20a651b9
This diff is collapsed.
Click to expand it.
tests/problemRectificationArchiveSnapshot.test.mjs
0 → 100644
View file @
20a651b9
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
,
),
/预计整改完成时间.*不是有效日期/
,
);
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment