Commit a2e249fe authored by David Yang's avatar David Yang

feat: integrate audit material center in problem detail

parent ceba014b
......@@ -53,10 +53,12 @@ export default function SubjectViewContent({
editSessionId,
mode = 'view',
onFieldChange,
onPendingLocalMaterialChange,
onFieldValidation,
readonly,
isFieldDisabled,
minimal = false,
useAuditMaterialCenter = false,
}) {
const displayFields = buildViewColumns(view, subject);
const contentFields = minimal
......@@ -119,8 +121,10 @@ export default function SubjectViewContent({
)
}
onValidationChange={onFieldValidation}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
readonly={readonly}
disabled={fieldDisabled}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
</div>
</div>
......
......@@ -15,6 +15,7 @@ import FileUploadField from '@/components/FileUploadField';
import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import ProblemRectificationMaterialField from '@/pages/problem_rectification/components/ProblemRectificationMaterialField';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import {
dateInputValue,
......@@ -303,7 +304,9 @@ function ControlledFieldEditor({
fieldVariant,
placeholder,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
useAuditMaterialCenter,
}) {
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
......@@ -388,6 +391,22 @@ function ControlledFieldEditor({
}
if (fieldType === 'file') {
if (
useAuditMaterialCenter &&
fieldPath === 'metadata.rectification_proof_materials'
) {
return (
<ProblemRectificationMaterialField
editSessionId={editSessionId}
fieldPath={fieldPath}
onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange}
value={value}
/>
);
}
return (
<Card variant="secondary">
<Card.Content className="space-y-3">
......@@ -518,7 +537,9 @@ export default function TailwindEditableField({
placeholder,
onSave,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
useAuditMaterialCenter = false,
mode = 'inline',
readonly = false,
disabled = false,
......@@ -559,7 +580,9 @@ export default function TailwindEditableField({
fieldVariant={fieldVariant}
placeholder={textPlaceholder}
onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
);
}
......
......@@ -4,6 +4,7 @@ import { Close } from '@/components/AppIcons';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip } from '@heroui/react';
import React, {
useCallback,
useContext,
forwardRef,
useEffect,
useImperativeHandle,
......@@ -13,6 +14,8 @@ import React, {
} from 'react';
import { useSubjectRecord } from './hooks/useSubjectRecord';
import SubjectViewContent from './components/SubjectViewContent';
import { UserContext } from '@/wrapper/Auth';
import { createProblemRectificationMaterial } from '@/pages/problem_rectification/problemRectificationMaterialService';
import {
buildViewColumns,
checkViewCondition,
......@@ -58,8 +61,11 @@ const RecordDetailDrawer = (props, ref) => {
disabledFields = EMPTY_ARRAY,
authorizationView = null,
deriveFieldValues,
useAuditMaterialCenter = false,
} = props;
const currentUser = useContext(UserContext);
const [refreshKey, setRefreshKey] = useState(() => Date.now());
const [isFullscreen, setIsFullscreen] = useState(false);
const [activeTabKey, setActiveTabKey] = useState();
......@@ -70,6 +76,7 @@ const RecordDetailDrawer = (props, ref) => {
const [validationErrors, setValidationErrors] = useState({});
const [saveError, setSaveError] = useState('');
const editSessionRef = useRef(0);
const pendingLocalMaterialsRef = useRef({});
const {
editableRecord,
......@@ -100,6 +107,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1;
}, [open, recordName]);
......@@ -200,6 +208,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
};
const handleRequestClose = () => {
......@@ -215,6 +224,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
setIsEditing(true);
};
......@@ -257,6 +267,21 @@ const RecordDetailDrawer = (props, ref) => {
});
}, []);
const handlePendingLocalMaterialChange = useCallback(
(fieldPath, pendingMaterial, editSessionId) => {
if (editSessionId !== editSessionRef.current) return;
const nextPendingMaterials = { ...pendingLocalMaterialsRef.current };
if (pendingMaterial) {
nextPendingMaterials[fieldPath] = pendingMaterial;
} else {
delete nextPendingMaterials[fieldPath];
}
pendingLocalMaterialsRef.current = nextPendingMaterials;
},
[],
);
const handleSave = async () => {
if (!hasDirtyFields || hasValidationErrors || isSaving) return;
......@@ -264,10 +289,30 @@ const RecordDetailDrawer = (props, ref) => {
setSaveError('');
try {
const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current)
.filter(([fieldPath]) => dirtyFields[fieldPath]);
for (const [fieldPath, pendingMaterial] of pendingMaterials) {
if (pendingMaterial.materialRecordId) continue;
const createdMaterial = await createProblemRectificationMaterial({
fileAsset: pendingMaterial.fileAsset,
currentUser,
});
pendingLocalMaterialsRef.current = {
...pendingLocalMaterialsRef.current,
[fieldPath]: {
...pendingMaterial,
materialRecordId: createdMaterial.id,
},
};
}
await handleFieldsUpdate(Object.values(dirtyFields));
setIsEditing(false);
setDirtyFields({});
setValidationErrors({});
pendingLocalMaterialsRef.current = {};
onDataChange?.(true);
} catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
......@@ -389,9 +434,11 @@ const RecordDetailDrawer = (props, ref) => {
mode={isEditing ? 'edit' : 'view'}
onFieldChange={handleDraftFieldChange}
onFieldValidation={handleFieldValidation}
onPendingLocalMaterialChange={handlePendingLocalMaterialChange}
readonly={effectiveReadonly}
isFieldDisabled={isDrawerFieldDisabled}
minimal={minimalContent}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
</div>
)}
......
import {
ArrowPathIcon,
ChevronDown,
Close,
Delete,
FIELD_SUFFIX_ICON_STYLE,
Search,
} from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField';
import {
queryAuditMaterials,
uploadProblemRectificationFile,
} from '../problemRectificationMaterialService';
import {
Alert,
Autocomplete,
Button,
Label,
ListBox,
SearchField,
Spinner,
Tabs,
Tooltip,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
const MATERIAL_CENTER_TAB = 'material-center';
const LOCAL_UPLOAD_TAB = 'local-upload';
function formatMaterialDescription(material) {
return [
material.fileAsset?.name,
material.archiveCategory,
material.archiveYear,
].filter(Boolean).join(' · ');
}
export default function ProblemRectificationMaterialField({
editSessionId,
fieldPath,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
value,
}) {
const [activeTab, setActiveTab] = useState(MATERIAL_CENTER_TAB);
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState('');
const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [localFileAsset, setLocalFileAsset] = useState(null);
useEffect(() => {
setActiveTab(MATERIAL_CENTER_TAB);
setKeyword('');
setDebouncedKeyword('');
setSelectedMaterialId(null);
setMaterialsError('');
setUploadError('');
setLocalFileAsset(null);
}, [editSessionId]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedKeyword(keyword);
}, 300);
return () => window.clearTimeout(timeoutId);
}, [keyword]);
useEffect(() => {
if (activeTab !== MATERIAL_CENTER_TAB) return undefined;
let cancelled = false;
setMaterialsLoading(true);
setMaterialsError('');
queryAuditMaterials(debouncedKeyword)
.then((nextMaterials) => {
if (!cancelled) setMaterials(nextMaterials);
})
.catch((error) => {
if (!cancelled) {
setMaterials([]);
setMaterialsError(error?.message || '审计材料加载失败,请稍后重试。');
}
})
.finally(() => {
if (!cancelled) setMaterialsLoading(false);
});
return () => {
cancelled = true;
};
}, [activeTab, debouncedKeyword, retryKey]);
const selectedMaterial = useMemo(
() => materials.find((material) => material.id === selectedMaterialId),
[materials, selectedMaterialId],
);
const selectedValueText = selectedMaterial?.materialTitle || value?.name || '请选择审计材料';
const handleMaterialSelection = (key) => {
const materialId = key ? String(key) : '';
const material = materials.find((item) => String(item.id) === materialId);
if (!material?.fileAsset?.url) return;
setSelectedMaterialId(material.id);
setLocalFileAsset(null);
setUploadError('');
onPendingLocalMaterialChange?.(fieldPath, null, editSessionId);
onChange?.({
name: material.fileAsset.name,
type: material.fileAsset.type || 'application/octet-stream',
size: Number(material.fileAsset.size || 0),
url: material.fileAsset.url,
});
};
const handleLocalUpload = async (files) => {
const file = files?.[0];
if (!file) return;
setUploading(true);
setUploadError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
try {
const fileAsset = await uploadProblemRectificationFile(file);
setLocalFileAsset(fileAsset);
setSelectedMaterialId(null);
onPendingLocalMaterialChange?.(
fieldPath,
{ fileAsset, materialRecordId: null },
editSessionId,
);
onChange?.(fileAsset);
} catch (error) {
setUploadError(error?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
}
};
const handleRemove = () => {
setSelectedMaterialId(null);
setLocalFileAsset(null);
setUploadError('');
onPendingLocalMaterialChange?.(fieldPath, null, editSessionId);
onChange?.(null);
};
return (
<div className="space-y-4">
{value?.name || value?.url ? (
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground" title={value.name || value.url}>
{value.name || value.url}
</div>
<div className="mt-1 text-xs text-muted">
{localFileAsset ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}
</div>
</div>
<Tooltip>
<Button
aria-label="删除当前附件"
isIconOnly
size="sm"
variant="danger-soft"
onPress={handleRemove}
>
<Delete size={16} />
</Button>
<Tooltip.Content>删除当前附件</Tooltip.Content>
</Tooltip>
</div>
) : (
<span className="text-sm text-muted">未上传</span>
)}
<Tabs
aria-label="附件来源"
className="w-fit max-w-full min-w-0"
selectedKey={activeTab}
variant="secondary"
onSelectionChange={(key) => setActiveTab(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={MATERIAL_CENTER_TAB}>
材料中心
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={LOCAL_UPLOAD_TAB}>
本地上传
<Tabs.Indicator />
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<div className="space-y-3">
<Autocomplete
aria-label="选择审计材料"
fullWidth
selectedKey={selectedMaterialId}
selectionMode="single"
variant="secondary"
onSelectionChange={handleMaterialSelection}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
<span className="block min-w-0 truncate" title={selectedValueText}>
{selectedValueText}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement="bottom start">
<Autocomplete.Filter
filter={() => true}
inputValue={keyword}
onInputChange={setKeyword}
>
<SearchField aria-label="搜索审计材料" fullWidth variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder="搜索材料标题或文件名" />
<SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox
aria-label="审计材料列表"
items={materialsLoading || materialsError ? [] : materials}
renderEmptyState={() => (
<div className="grid min-h-24 place-items-center px-4 py-5 text-sm text-muted">
{materialsLoading ? <Spinner size="sm" /> : materialsError || '没有找到可选择的审计材料'}
</div>
)}
>
{(material) => (
<ListBox.Item
id={material.id}
textValue={`${material.materialTitle} ${material.fileAsset?.name || ''}`}
>
<span className="min-w-0">
<Label className="block truncate" title={material.materialTitle}>
{material.materialTitle}
</Label>
<span className="mt-0.5 block truncate text-xs text-muted" title={formatMaterialDescription(material)}>
{formatMaterialDescription(material)}
</span>
</span>
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
{materialsError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{materialsError}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重试
</Button>
</Alert>
) : null}
</div>
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}>
<div className="space-y-3">
<FileUploadField
description="上传后先保留为详情草稿,保存详情时同步写入审计材料中心。"
fileName={localFileAsset?.name || ''}
isDisabled={uploading}
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload}
/>
{uploadError ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content>
</Alert>
) : null}
</div>
</Tabs.Panel>
</Tabs>
</div>
);
}
......@@ -1872,6 +1872,7 @@ export default function ProblemRectificationListPage() {
}}
preferredViewName={activeGridView?.title || activeGridView?.name}
authorizationView={activeGridView}
useAuditMaterialCenter
/>
),
}}
......
......@@ -142,7 +142,13 @@
- `problem_rectification_archive_records`:归档后的问题快照明细
- `issue_rectification_archive`:一次归档批次主单
### 4.3 流程辅助对象
### 4.3 审计材料共享对象
- `am_material`:审计材料中心共享主档。问题清单详情可查询已有主档;从本地上传证明材料并保存详情时同步创建主档
问题记录仍在 `metadata.rectification_proof_materials` 保存 `{name,type,size,url}` 文件快照,不保存材料主档 `_id`,不建立自动联动关系。
### 4.4 流程辅助对象
- `message_reference_id`(信息关联id):旧应用中用于承载“推送填写、复核、最终审核”状态的消息流程主单
......@@ -745,9 +751,18 @@
- 使用抽屉或对话框,而不是表格内直接改单元格
- 基础信息与整改信息分区展示
- 附件上传单独区域展示
- 附件在单独区域展示,编辑时默认进入“材料中心”,也可切换到“本地上传”
- 状态、责任人、计划时间等字段用结构化控件维护
整改证明材料交互限定在问题清单详情弹框,不改变整改处理页、整改流程表格和归档详情:
1. “材料中心”首次读取当前用户可访问的最近 20 条 `am_material`,支持按材料标题或文件名远程搜索;选中后仅把 `metadata.file_asset` 快照写入详情草稿。
2. “本地上传”选择文件后立即调用 `/upload`,上传期间禁止保存;成功后将文件对象写入详情草稿,并提示保存后同步到材料中心。
3. 用户点击详情“保存”时,先创建 `am_material`,写入 `source_app = issue_rectification`、当前用户 `_id` 和 ISO 上传时间,再更新 `problem_rectification.metadata.rectification_proof_materials`。
4. 用户取消编辑时不创建材料主档;选择已有材料不重复上传、不创建新主档。
5. 材料创建失败时保留编辑草稿且不更新问题;材料创建成功但问题更新失败时保留已建档标记,再次保存只重试问题更新,避免重复材料。
6. 清空或替换问题附件不删除材料中心主档或底层文件;已有材料后续修改或软删除不联动历史问题快照。
建议字段分组:
- 基础信息
......
import dayjs from 'dayjs';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
export const ISSUE_RECTIFICATION_SOURCE_APP = 'issue_rectification';
const MATERIAL_PAGE_SIZE = 20;
function extractRecords(response) {
const candidates = [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
response?.result?.records,
];
return candidates.find((candidate) => Array.isArray(candidate)) || [];
}
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function inferMaterialTitle(fileName = '') {
return String(fileName || '').replace(/\.[^.]+$/, '').trim() || '未命名材料';
}
function normalizeFileAsset(uploadResult, file) {
const candidates = [
uploadResult?.file,
uploadResult?.data?.file,
uploadResult?.data,
uploadResult?.result?.file,
uploadResult?.result,
uploadResult,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'object') continue;
const url =
candidate.url ||
candidate.path ||
candidate.location ||
candidate?.data?.url ||
'';
if (!url) continue;
return {
name: candidate.name || candidate.filename || file?.name || '未命名文件',
type: candidate.type || candidate.mimetype || file?.type || 'application/octet-stream',
size: Number(candidate.size || file?.size || 0),
url,
};
}
throw new Error('文件上传成功,但未获取到可用的文件地址。');
}
function normalizeMaterial(record = {}) {
const metadata = record?.metadata || {};
const fileAsset = metadata.file_asset || {};
return {
id: record._id || record.id || record.name || '',
materialTitle: metadata.material_title || record.title || fileAsset.name || '未命名材料',
fileAsset,
archiveCategory: metadata.archive_category || '',
archiveYear: metadata.archive_year || '',
sourceApp: metadata.source_app || '',
uploadedAt: metadata.uploaded_at || record.created_at || '',
};
}
export async function queryAuditMaterials(keyword = '') {
const normalizedKeyword = String(keyword || '').trim();
const escapedKeyword = escapeRegExp(normalizedKeyword);
const filter = escapedKeyword
? {
$or: [
{
'metadata.material_title': {
$regex: escapedKeyword,
$options: 'i',
},
},
{
'metadata.file_asset.name': {
$regex: escapedKeyword,
$options: 'i',
},
},
],
}
: {};
const response = await queryRecords({
subject: AUDIT_MATERIAL_SUBJECT,
page: 0,
page_size: MATERIAL_PAGE_SIZE,
filter,
sort: '-updated_at -created_at',
mode: 'full',
});
return extractRecords(response)
.map(normalizeMaterial)
.filter((material) => material.id && material.fileAsset?.url);
}
export async function uploadProblemRectificationFile(file) {
if (!file) {
throw new Error('请选择要上传的本地文件。');
}
const uploadResult = await uploadFile(file);
return normalizeFileAsset(uploadResult, file);
}
export async function createProblemRectificationMaterial({ fileAsset, currentUser }) {
if (!fileAsset?.url) {
throw new Error('缺少有效的文件地址,无法写入审计材料中心。');
}
const uploaderId = currentUser?._id || currentUser?.id || '';
if (!uploaderId) {
throw new Error('未获取到当前登录用户,无法写入审计材料中心。');
}
const materialTitle = inferMaterialTitle(fileAsset.name);
const response = await createRecord({
subject: AUDIT_MATERIAL_SUBJECT,
title: materialTitle,
content_type: 'text',
metadata: {
material_title: materialTitle,
file_asset: {
name: fileAsset.name,
type: fileAsset.type || 'application/octet-stream',
size: Number(fileAsset.size || 0),
url: fileAsset.url,
},
source_app: ISSUE_RECTIFICATION_SOURCE_APP,
uploader: uploaderId,
uploaded_at: dayjs().toISOString(),
},
});
const createdRecord = response?.data || response?.result || response;
return {
id: createdRecord?._id || createdRecord?.id || createdRecord?.name || true,
record: createdRecord,
};
}
# Record 接口文档
**最后更新**:2026 年 3 月
**最后更新**:2026 年 8 月
**基于代码实现整理**
参考文件:
- `src/routers/record.js`
......@@ -267,5 +267,67 @@ Query:`calculated=true|false`
6. **软删除 vs 物理删除**:默认软删除(status=removed),需显式 `force` 才物理删除。
7. **导入接口**:支持 name 自动转 id,但仅限同一 subject 内查找。
如需补充特定 subject 的 metadata 字段规范、文件上传流程、权限点详细对应表等,请继续提供信息,我可以进一步扩展对应章节。
## 5. 问题详情与审计材料中心接入
问题清单详情弹框中的 `metadata.rectification_proof_materials` 支持从统一材料池选择已有文件,或上传本地文件后同步建档。整改处理页、整改流程表格和归档详情不使用本节流程。
### 5.1 查询已有材料
调用 `POST /record/query`,按当前用户的 Record 访问权限返回有效材料:
```json
{
"subject": "am_material",
"page": 0,
"page_size": 20,
"filter": {
"$or": [
{
"metadata.material_title": {
"$regex": "整改说明",
"$options": "i"
}
},
{
"metadata.file_asset.name": {
"$regex": "整改说明",
"$options": "i"
}
}
]
},
"sort": "-updated_at -created_at",
"mode": "full"
}
```
搜索关键词必须先转义正则特殊字符。选中材料后,只将 `metadata.file_asset` 的 `{name,type,size,url}` 快照写入问题详情草稿,不保存 `am_material` `_id`。
### 5.2 本地上传并写入材料中心
1. 选择文件后立即调用 `POST /upload`,将返回结果规范化为 `{name,type,size,url}` 并保留在详情草稿。
2. 用户点击详情“保存”时调用 `POST /record` 创建材料主档:
```json
{
"subject": "am_material",
"title": "整改说明",
"content_type": "text",
"metadata": {
"material_title": "整改说明",
"file_asset": {
"name": "整改说明.pdf",
"type": "application/pdf",
"size": 204800,
"url": "https://example.com/files/proof.pdf"
},
"source_app": "issue_rectification",
"uploader": "69b12a47e92b1de77c94a9f5",
"uploaded_at": "2026-08-25T08:30:00.000Z"
}
}
```
3. 材料主档创建成功后,再调用 `PUT /record/:name` 更新问题记录的 `metadata.rectification_proof_materials`。
4. 材料创建失败时不更新问题记录;材料创建成功但问题更新失败时,当前编辑会话记住已创建的材料,重试保存不得重复创建。
5. 取消详情编辑不创建 `am_material`;已经上传但未建档的二进制文件遵循上传服务自身的清理策略。
......@@ -4,10 +4,10 @@
本文件定义当前项目使用的 Subject(业务对象)字段协议,供前端页面、服务层、动态表单与 AI 代码生成使用。
当前项目当前涉及两个业务模块:
当前项目涉及以下业务对象:
- `pm_project_task`:项目管理
- `problem_rectification`:问题整改
- `am_material`:审计材料中心共享主档,仅供问题详情查询和本地附件同步建档
---
......@@ -187,6 +187,7 @@ type Record = {
- 新增驾驶舱扩展字段当前在线上 Subject 中大多仍为 `text` 类型,前端工作台按标准值引导录入,用于支持后续严重程度、问题分类、问题原因和整改质效分析
- 其余 `select` 类型的可选值必须以线上 Subject 配置中的 `settings.options` 为准,禁止在页面内硬编码
- `problem_discovery_situation` 曾在历史文档和归档对象中出现,但本次主对象字段定义未包含;新页面只应在 Subject View 或线上字段配置存在时展示,不能作为 `problem_rectification` 固定字段依赖
- 问题清单详情弹框中的 `rectification_proof_materials` 支持从 `am_material.metadata.file_asset` 复制文件快照,或上传本地文件后创建 `am_material`。问题字段仍只保存 `{name,type,size,url}`,不保存材料主档 `_id`,材料后续修改或软删除不反向联动问题记录
- `department_rectification_items` 用于新转发流程的部门级整改明细。无论单条还是多条推送,每个问题都按责任部门生成明细;单条问题即使只有 1 个责任部门,也生成 1 条明细。已推送过的问题再次推送时,必须保留原部门明细记录和 `item_id`,只在本次参与推送的原明细行上更新填写人、审批人、流程状态和待办 ID;未参与本次推送的原明细行保持不变,不因弹窗删除部门配置而从父记录移除。转发后 `rectification_progress`、`rectification_situation`、`the_time_of_rectification`、`rectification_proof_materials`、`audit_status`、`final_audit_status` 以明细为事实来源,父字段仅做兼容汇总展示;整改进度、审批状态和最终审批状态按部门明细中最落后的状态汇总,例如“整改中”优先于“已整改”,“未审批”优先于“已审批”
- `problem_rectification_delay_request` 为历史问题级单次延期申请嵌套对象,与 `department_rectification_items` 平级;新发起延期默认按部门写入 `problem_rectification_delay_request_items`
- `problem_rectification_delay_request_items` 为部门级延期申请明细数组;每个责任部门 1 条延期申请,独立保存填写人、一级审批人、最终审批人、申请完成时间、延期事由、状态和待办 ID
......@@ -290,7 +291,26 @@ type Record = {
}
```
## 3.2 问题整改归档明细
## 3.2 审计材料中心共享主档
- **subject**: `am_material`
本项目只使用该对象完成问题详情的材料查询和本地附件同步建档,不修改线上 Subject 配置。
| 字段标签 | 字段别名 | 字段类型 | 是否必填 | 问题详情写入规则 |
| --- | --- | --- | --- | --- |
| 材料标题 | `material_title` | text | 是 | 默认取文件名去后缀 |
| 文件对象 | `file_asset` | file | 是 | `{name,type,size,url}` |
| 来源应用 | `source_app` | text | 否 | 固定写入 `issue_rectification` |
| 上传人 | `uploader` | user | 是 | 当前登录用户 `_id` |
| 上传时间 | `uploaded_at` | date | 是 | ISO 8601 字符串 |
| 来源系统 | `source_system` | select | 否 | 问题详情不写入 |
| 材料分类 | `archive_category` | select | 否 | 问题详情不写入 |
| 所属周期/年份 | `archive_year` | text | 否 | 问题详情不写入 |
问题详情选择已有材料时读取 `material_title` 与 `file_asset`;搜索范围为 `metadata.material_title` 和 `metadata.file_asset.name`。列表只展示当前登录用户通过 Record 权限可读取且 `file_asset.url` 有效的记录。
## 3.3 问题整改归档明细
- **subject**: `problem_rectification_archive_records`
......@@ -319,14 +339,14 @@ type Record = {
| 检查项目名称 | `project_name` | text | 否 | 否 | 否 |
| 整改证明材料 | `rectification_proof_materials` | file | 否 | 否 | 否 |
### 3.2.1 归档写入约定
### 3.3.1 归档写入约定
- 归档对象存的是快照,不再要求保留原来的 `select` 选项结构
- 旧应用归档时会把 `responsible_department` 转为逗号拼接文本
- 归档记录仍需保留 `fill_in_the_people`、`review_people` 的用户 `_id[]`
- `the_time_of_rectification` 继续按 ISO 字符串写入
## 3.3 问题整改归档批次
## 3.4 问题整改归档批次
- **subject**: `issue_rectification_archive`
......@@ -336,7 +356,7 @@ type Record = {
| --- | --- | --- | --- | --- | --- |
| 归档明细记录 | `reference_records` | reference | 否 | 是 | 是 |
### 3.3.1 metadata 示例
### 3.4.1 metadata 示例
```json
{
......@@ -347,7 +367,7 @@ type Record = {
}
```
## 3.4 信息关联id(问题整改流程消息主单)
## 3.5 信息关联id(问题整改流程消息主单)
- **subject**: `message_reference_id`
- **对象名称**: 信息关联id
......@@ -373,7 +393,7 @@ type Record = {
| 审核状态 | `auditor_status` | select | 否 | 否 | 否 |
| 发送人状态 | `sender_status` | select | 否 | 否 | 否 |
### 3.4.1 当前流程写入约定
### 3.5.1 当前流程写入约定
- `restriction_key` 默认写 `_id`
- `restriction_value` 保存被推送整改记录的 `_id[]`
......@@ -388,7 +408,7 @@ type Record = {
- `sender_status`:当前项目统一使用 `未完成`、`已完成`
- 当批量流程复核或最终审核驳回时,流程主单 `editor_status` 重置为 `未完成` 并回写新的 `progress_id_editor`,同一批次的全部问题共同重新进入填写阶段
## 3.5 项目管理
## 3.6 项目管理
- **subject**: `pm_project_task`
......@@ -414,7 +434,7 @@ type Record = {
| 延期原因 | `delay_reason` | text | 否 | 否 | 否 |
| 周进度记录 | `weekly_updates` | object | 否 | 是 | 否 |
### 3.5.1 `project_status` 选项
### 3.6.1 `project_status` 选项
| 显示值 | 存储值 |
| --- | --- |
......@@ -425,7 +445,7 @@ type Record = {
| 受阻 | `blocked` |
| 已取消 | `cancelled` |
### 3.5.2 `priority` 建议选项
### 3.6.2 `priority` 建议选项
| 显示值 | 存储值 |
| --- | --- |
......@@ -433,7 +453,7 @@ type Record = {
| 中 | `medium` |
| 低 | `low` |
### 3.5.3 `result_status` 建议选项
### 3.6.3 `result_status` 建议选项
| 显示值 | 存储值 |
| --- | --- |
......@@ -444,7 +464,7 @@ type Record = {
| 准时完成 | `completed_on_time` |
| 超时完成 | `completed_overdue` |
### 3.5.4 `weekly_updates` 子结构建议
### 3.6.4 `weekly_updates` 子结构建议
`weekly_updates` 为对象数组,建议每项至少包含以下字段:
......@@ -465,7 +485,7 @@ type Record = {
- 周进度记录通过“新增本周进度 / 编辑周进度”弹窗录入
- 列表页或项目详情只展示最近一条摘要,不直接暴露底层 JSON
### 3.5.5 metadata 示例
### 3.6.5 metadata 示例
```json
{
......
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