Commit 963b3a6e authored by David Yang's avatar David Yang

feat: 完善模型同步与转发审批流程

parent a46c3e8d
......@@ -40,6 +40,27 @@ const FILTER_OPTIONS = [
{ value: 'involved', label: '我参与的' },
];
function isReturnedForEditing(record) {
return record?.approvalStatus === 'rejected' || record?.finalApprovalStatus === 'rejected';
}
function canRecipientEdit(record, currentUserId) {
return Boolean(
record?.recipientIds.includes(currentUserId) &&
(record.editorStatus !== 'completed' || isReturnedForEditing(record)),
);
}
function getForwardEditorStatusLabel(record) {
return isReturnedForEditing(record) ? '待重新填写' : getEditorStatusLabel(record?.editorStatus);
}
function getForwardEditorStatusTone(record) {
return !isReturnedForEditing(record) && record?.editorStatus === 'completed'
? 'emerald'
: 'amber';
}
export default function ForwardApprovals() {
const userInfo = useContext(UserContext);
const currentUserId = userInfo?._id || userInfo?.id || userInfo?.username || '';
......@@ -69,9 +90,7 @@ export default function ForwardApprovals() {
if (!cancelled) {
setRecords(result);
const hasEditableInvolvedRecord = result.some(
(record) =>
record.recipientIds.includes(currentUserId) &&
record.editorStatus !== 'completed',
(record) => canRecipientEdit(record, currentUserId),
);
if (hasEditableInvolvedRecord) {
setActiveFilter('involved');
......@@ -97,9 +116,7 @@ export default function ForwardApprovals() {
const editableInvolvedCount = useMemo(
() =>
records.filter(
(record) =>
record.recipientIds.includes(currentUserId) &&
record.editorStatus !== 'completed',
(record) => canRecipientEdit(record, currentUserId),
).length,
[currentUserId, records],
);
......@@ -262,6 +279,8 @@ export default function ForwardApprovals() {
completed_by_name:
userInfo?.display_name || userInfo?.displayName || userInfo?.username || '',
completed_at: new Date().toISOString(),
approval_status: 'pending',
final_approval_status: 'pending',
});
setEditingRecord(updatedForwardRecord);
......@@ -301,6 +320,10 @@ export default function ForwardApprovals() {
record.approvalStatus === 'pending'
) {
await handleUpdateRecord(record, {
editor_status: 'editing',
completed_by_id: '',
completed_by_name: '',
completed_at: '',
approval_status: 'rejected',
final_approval_status: 'pending',
});
......@@ -312,14 +335,18 @@ export default function ForwardApprovals() {
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending'
) {
await handleUpdateRecord(record, { final_approval_status: 'rejected' });
await handleUpdateRecord(record, {
editor_status: 'editing',
completed_by_id: '',
completed_by_name: '',
completed_at: '',
final_approval_status: 'rejected',
});
}
}
const canCurrentUserEdit = Boolean(
activeRecord &&
activeRecord.recipientIds.includes(currentUserId) &&
activeRecord.editorStatus !== 'completed',
canRecipientEdit(activeRecord, currentUserId),
);
const canCurrentUserViewData = Boolean(
......@@ -332,9 +359,7 @@ export default function ForwardApprovals() {
);
const canEditingRecordEdit = Boolean(
editingRecord &&
editingRecord.recipientIds.includes(currentUserId) &&
editingRecord.editorStatus !== 'completed',
canRecipientEdit(editingRecord, currentUserId),
);
const activeEditableRecord = useMemo(
......@@ -459,8 +484,8 @@ export default function ForwardApprovals() {
</div>
</div>
<div className="flex flex-wrap gap-2">
<Tag tone={record.editorStatus === 'completed' ? 'emerald' : 'amber'}>
{getEditorStatusLabel(record.editorStatus)}
<Tag tone={getForwardEditorStatusTone(record)}>
{getForwardEditorStatusLabel(record)}
</Tag>
<ApprovalStatusTag status={record.approvalStatus} />
<ApprovalStatusTag status={record.finalApprovalStatus} />
......@@ -495,8 +520,8 @@ export default function ForwardApprovals() {
</div>
</div>
<div className="flex flex-wrap gap-2">
<Tag tone={activeRecord.editorStatus === 'completed' ? 'emerald' : 'amber'}>
{getEditorStatusLabel(activeRecord.editorStatus)}
<Tag tone={getForwardEditorStatusTone(activeRecord)}>
{getForwardEditorStatusLabel(activeRecord)}
</Tag>
<ApprovalStatusTag status={activeRecord.approvalStatus} />
<ApprovalStatusTag status={activeRecord.finalApprovalStatus} />
......@@ -529,10 +554,12 @@ export default function ForwardApprovals() {
<div className="rounded-lg border border-zinc-200 bg-white p-4">
<div className="text-sm font-medium text-zinc-500">填写状态</div>
<div className="mt-2 text-base font-semibold text-zinc-950">
{getEditorStatusLabel(activeRecord.editorStatus)}
{getForwardEditorStatusLabel(activeRecord)}
</div>
<div className="mt-2 text-sm text-zinc-500">
{activeRecord.completedByName
{isReturnedForEditing(activeRecord)
? '审批已驳回,接收人可修改后重新提交'
: activeRecord.completedByName
? `已由 ${activeRecord.completedByName} 于 ${formatDateTime(activeRecord.completedAt)} 完成填写`
: '接收人仍可进入填写并保存修改'}
</div>
......@@ -569,7 +596,7 @@ export default function ForwardApprovals() {
</Button>
</div>
<div className="mt-3 text-xs leading-5 text-zinc-500">
规则:填写人、一级审批人和终审人都可以查看这批次数据;只有接收人且批次仍处于填写中时可以修改。任意一位接收人点击“完成填写”后,这批数据即锁定为不可修改,并进入待审批状态。
规则:填写人、一级审批人和终审人都可以查看这批次数据;接收人可在填写中或审批驳回后修改。重新完成填写后,从一级审批重新开始。
</div>
</div>
......@@ -614,7 +641,7 @@ export default function ForwardApprovals() {
</Button>
</div>
<div className="mt-3 text-xs leading-5 text-zinc-500">
规则:终审只能在一级审批通过后推进;如果一级审批驳回,最终审批状态保持待处理。
规则:终审只能在一级审批通过后推进;任一环节驳回都会退回接收人修改,重新提交后从一级审批开始。
</div>
</div>
......
......@@ -231,18 +231,18 @@ export default function ModelBatchData() {
{loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
{batch?.syncStatus === 'partial' || batch?.syncStatus === 'failed' ? <NoticeBanner tone="error">最近一次同步未完整成功:{batch.errorMessage || '请返回批次页重新同步。'}</NoticeBanner> : null}
{batch?.syncStatus === 'partial' || batch?.syncStatus === 'failed' ? <NoticeBanner tone="error">最近一次同步未完整成功:{batch.errorMessage || '该批次不可重试,请联系管理员排查。'}</NoticeBanner> : null}
{!loading && batch ? (
<Panel className="min-w-0 p-4 sm:p-5">
<SectionHeader title="批次数据" description={`当前展示 ${visibleRecords.length} 条,手动隐藏 ${hiddenRecords.length} 条,流程临时隐藏 ${forwardStatusIndex.flowHiddenRecordIds.size} 条。`} action={<Button outline onClick={() => setIsHiddenDialogOpen(true)} disabled={!hiddenRecords.length}><EyeIcon className="size-4" />管理已隐藏</Button>} />
<SelectionToolbar count={selectedRecordIds.length} onForward={openForwardDialog} onHide={handleHideSelected} onClear={() => setSelectedRecordIds([])} />
{pageRecords.length ? <RecordsResultTable records={pageRecords} columns={batch.columnSchema} selectedIds={selectedRecordIds} rowStatusByRecordId={rowStatusByRecordId} forwardItemByRecordId={forwardItemByRecordId} onOpenForwardItem={(record, item) => setForwardItemDetail({ record, item })} onToggleRow={handleToggleRow} onToggleAll={handleToggleAll} /> : <EmptyCollection title="当前批次没有可展示数据" description={allRecords.length ? '数据可能已被手动隐藏,或因未结束转发中的“不下发”设置暂时隐藏。' : '请返回批次页重新同步,或确认动态数据对象中是否已有记录。'} />}
{pageRecords.length ? <RecordsResultTable records={pageRecords} columns={batch.columnSchema} selectedIds={selectedRecordIds} rowStatusByRecordId={rowStatusByRecordId} forwardItemByRecordId={forwardItemByRecordId} onOpenForwardItem={(record, item) => setForwardItemDetail({ record, item })} onToggleRow={handleToggleRow} onToggleAll={handleToggleAll} /> : <EmptyCollection title="当前批次没有可展示数据" description={allRecords.length ? '数据可能已被手动隐藏,或因未结束转发中的“不下发”设置暂时隐藏。' : '请查看批次错误摘要,或确认动态数据对象中是否已有记录。'} />}
{visibleRecords.length > PAGE_SIZE ? <div className="mt-4 flex items-center justify-between border-t border-zinc-200 pt-4"><Button outline disabled={page === 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>上一页</Button><span className="text-sm tabular-nums text-zinc-500">第 {page + 1} / {pageCount} 页</span><Button outline disabled={page + 1 >= pageCount} onClick={() => setPage((value) => Math.min(pageCount - 1, value + 1))}>下一页</Button></div> : null}
</Panel>
) : null}
<Dialog open={isHiddenDialogOpen} onClose={setIsHiddenDialogOpen} size="xl"><DialogTitle>管理已隐藏记录</DialogTitle><DialogDescription>隐藏范围属于当前模型批次,对所有用户生效;恢复后记录会重新出现在数据页。</DialogDescription><DialogBody>{hiddenRecords.length ? <div className="divide-y divide-zinc-100">{hiddenRecords.map((record) => <div key={record.recordName || record.id} className="flex items-center justify-between gap-4 py-3"><div className="min-w-0"><div className="truncate text-sm font-semibold text-zinc-900">{record.title}</div><div className="mt-1 truncate text-xs text-zinc-500">{record.sourceRecordKey}</div></div><Button outline disabled={saving} onClick={() => handleRestoreRecord(record.recordName || record.id)}><EyeIcon className="size-4" />恢复</Button></div>)}</div> : <EmptyCollection title="没有手动隐藏记录" description="当前批次的全部记录都处于可展示状态。" />}</DialogBody><DialogActions><Button plain onClick={() => setIsHiddenDialogOpen(false)}>关闭</Button></DialogActions></Dialog>
<Dialog open={isHiddenDialogOpen} onClose={setIsHiddenDialogOpen} size="xl"><DialogTitle>管理已隐藏记录</DialogTitle><DialogDescription>隐藏范围属于当前模型批次,对所有用户生效;恢复后记录会重新出现在数据页。</DialogDescription><DialogBody>{hiddenRecords.length ? <div className="divide-y divide-zinc-100">{hiddenRecords.map((record) => <div key={record.recordName || record.id} className="flex items-center justify-between gap-4 py-3"><div className="min-w-0"><div className="truncate text-sm font-semibold text-zinc-900">{record.title}</div><div className="mt-1 truncate text-xs text-zinc-500">{record.sourceRowNumber ? `来源第 ${record.sourceRowNumber} 条` : record.recordName}</div></div><Button outline disabled={saving} onClick={() => handleRestoreRecord(record.recordName || record.id)}><EyeIcon className="size-4" />恢复</Button></div>)}</div> : <EmptyCollection title="没有手动隐藏记录" description="当前批次的全部记录都处于可展示状态。" />}</DialogBody><DialogActions><Button plain onClick={() => setIsHiddenDialogOpen(false)}>关闭</Button></DialogActions></Dialog>
<Dialog open={isForwardDialogOpen} onClose={saving ? () => {} : setIsForwardDialogOpen} size="2xl"><DialogTitle>提交转发审批</DialogTitle><DialogDescription>勾选本次实际下发的数据;取消勾选的记录会作为“不下发”记录,在本流程结束前暂时从批次页隐藏。</DialogDescription><DialogBody className="space-y-5"><div><FieldLabel required>本次下发记录</FieldLabel><div className="max-h-56 divide-y divide-zinc-100 overflow-y-auto rounded-lg border border-zinc-200">{selectedRecords.map((record) => { const key = record.recordName || record.id; return <label key={key} className="flex min-h-11 cursor-pointer items-center gap-3 px-4 py-2 text-sm hover:bg-zinc-50"><Checkbox color="blue" checked={forwardForm.deliverRecordIds.includes(key)} onChange={(checked) => setForwardForm((current) => ({ ...current, deliverRecordIds: checked ? [...current.deliverRecordIds, key] : current.deliverRecordIds.filter((id) => id !== key) }))} /><span className="truncate">{record.title}</span></label>; })}</div></div><UserMultiSelectField values={forwardForm.recipients} onChange={(recipients) => setForwardForm((current) => ({ ...current, recipients }))} label="转发接收人" required /><div className="grid gap-4 sm:grid-cols-2"><UserSelectField value={forwardForm.firstApprover} onChange={(firstApprover) => setForwardForm((current) => ({ ...current, firstApprover }))} label="一级审批人" required /><UserSelectField value={forwardForm.finalApprover} onChange={(finalApprover) => setForwardForm((current) => ({ ...current, finalApprover }))} label="终审人" required /></div><div><FieldLabel required>转发说明</FieldLabel><Textarea rows={4} value={forwardForm.note} onChange={(event) => setForwardForm((current) => ({ ...current, note: event.target.value }))} /></div></DialogBody><DialogActions><Button plain disabled={saving} onClick={() => setIsForwardDialogOpen(false)}>取消</Button><Button color="blue" disabled={saving} onClick={handleSubmitForward}>提交审批</Button></DialogActions></Dialog>
......
......@@ -81,7 +81,7 @@ export default function ModelBatches() {
return batches.filter((batch) => !search || `${batch.batchName} ${batch.batchCode} ${batch.syncStatus}`.toLowerCase().includes(search));
}, [batches, keyword]);
const syncedBatchCodes = useMemo(
() => new Set(batches.filter((batch) => batch.syncStatus === 'success').map((batch) => batch.batchCode)),
() => new Set(batches.map((batch) => batch.batchCode)),
[batches],
);
......@@ -92,10 +92,12 @@ export default function ModelBatches() {
setProgress({ completed: 0, total: 0 });
try {
const sourceModels = await listSourceModels();
const matchedModel = sourceModels.find((item) => item.model_code === model.modelCode);
const matchedModel = sourceModels.find(
(item) => String(item.source_model_id) === model.sourceModelId && item.model_code === model.modelCode,
);
if (!matchedModel) throw new Error('外部模型列表中已找不到当前模型。');
setSourceModel(matchedModel);
setSourceBatches(await listSourceBatches(matchedModel.model_name));
setSourceBatches(await listSourceBatches(matchedModel));
} catch (loadError) {
setError(loadError?.message || '外部批次列表加载失败。');
setIsSyncDialogOpen(false);
......@@ -108,14 +110,14 @@ export default function ModelBatches() {
const sourceBatch = sourceBatches.find((item) => item.batch_code === selectedBatchCode);
if (!sourceModel || !sourceBatch) return;
if (syncedBatchCodes.has(sourceBatch.batch_code)) {
setError('该批次已经同步成功,不能再次同步。');
setError('该批次已经执行过同步,不能再次同步。');
return;
}
setSyncing(true);
try {
const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, setProgress);
await refresh(result.model);
setNotice(`批次“${sourceBatch.batch_name}”同步完成:新增 ${result.insertedCount} 条,更新 ${result.updatedCount} 条。`);
setNotice(`批次“${sourceBatch.batch_name}”同步完成:新增 ${result.insertedCount} 条。`);
setError('');
setIsSyncDialogOpen(false);
} catch (syncError) {
......@@ -155,7 +157,7 @@ export default function ModelBatches() {
</Panel>
) : null}
<Dialog open={isSyncDialogOpen} onClose={syncing ? () => {} : setIsSyncDialogOpen} size="xl"><DialogTitle>同步 {model?.modelName}</DialogTitle><DialogDescription>选择一个外部批次。已同步成功的批次不可再次同步,失败或部分成功的批次仍可重试。</DialogDescription><DialogBody className="space-y-4"><div><FieldLabel required>外部批次</FieldLabel><Select value={selectedBatchCode} disabled={syncing} onChange={(event) => setSelectedBatchCode(event.target.value)}><option value="">请选择批次</option>{sourceBatches.map((batch) => { const synced = syncedBatchCodes.has(batch.batch_code); return <option key={batch.batch_code} value={batch.batch_code} disabled={synced}>{batch.batch_name}({batch.batch_code}){synced ? ' - 已同步' : ''}</option>; })}</Select></div>{syncing && progress.total ? <div className="rounded-lg border border-sky-200 bg-sky-50 p-4"><div className="flex justify-between text-sm text-sky-900"><span>正在写入模型数据...</span><span className="tabular-nums">{progress.completed}/{progress.total}</span></div><div className="mt-3 h-2 overflow-hidden rounded-full bg-sky-100"><div className="h-full bg-sky-600 transition-all" style={{ width: `${Math.round(progress.completed / progress.total * 100)}%` }} /></div></div> : null}</DialogBody><DialogActions><Button plain disabled={syncing} onClick={() => setIsSyncDialogOpen(false)}>取消</Button><Button color="blue" disabled={!selectedBatchCode || syncedBatchCodes.has(selectedBatchCode) || syncing} onClick={handleSync}>开始同步</Button></DialogActions></Dialog>
<Dialog open={isSyncDialogOpen} onClose={syncing ? () => {} : setIsSyncDialogOpen} size="xl"><DialogTitle>同步 {model?.modelName}</DialogTitle><DialogDescription>选择一个外部批次。每个来源批次只能同步一次,执行后不可再次同步。</DialogDescription><DialogBody className="space-y-4"><div><FieldLabel required>外部批次</FieldLabel><Select value={selectedBatchCode} disabled={syncing} onChange={(event) => setSelectedBatchCode(event.target.value)}><option value="">请选择批次</option>{sourceBatches.map((batch) => { const synced = syncedBatchCodes.has(batch.batch_code); return <option key={batch.batch_code} value={batch.batch_code} disabled={synced}>{batch.batch_name}({batch.batch_code}){synced ? ' - 已同步' : ''}</option>; })}</Select></div>{syncing && progress.total ? <div className="rounded-lg border border-sky-200 bg-sky-50 p-4"><div className="flex justify-between text-sm text-sky-900"><span>正在写入模型数据...</span><span className="tabular-nums">{progress.completed}/{progress.total}</span></div><div className="mt-3 h-2 overflow-hidden rounded-full bg-sky-100"><div className="h-full bg-sky-600 transition-all" style={{ width: `${Math.round(progress.completed / progress.total * 100)}%` }} /></div></div> : null}</DialogBody><DialogActions><Button plain disabled={syncing} onClick={() => setIsSyncDialogOpen(false)}>取消</Button><Button color="blue" disabled={!selectedBatchCode || syncedBatchCodes.has(selectedBatchCode) || syncing} onClick={handleSync}>开始同步</Button></DialogActions></Dialog>
</div>
);
}
const MOCK_MODELS = [
{
source_model_id: '430',
source_model_name: 'AML_CUSTOMER_RISK',
model_code: 'AML_CUSTOMER_RISK',
model_name: '客户洗钱风险模型',
primary_key_fields: ['customer_id', 'risk_date'],
model_state: 'enabled',
model_apply_state: 'applied',
model_risk_level: 'high',
model_business_type: '12',
model_business_type_name: '风险分析',
source_model_updated_at: '2026-06-30 08:00:00',
},
{
source_model_id: '412',
source_model_name: 'TRANSACTION_ALERT',
model_code: 'TRANSACTION_ALERT',
model_name: '异常交易预警模型',
primary_key_fields: ['alert_id'],
model_state: 'enabled',
model_apply_state: 'applied',
model_risk_level: 'medium',
model_business_type: '12',
model_business_type_name: '风险分析',
source_model_updated_at: '2026-07-20 08:00:00',
},
{
source_model_id: '411',
source_model_name: 'CREDIT_EXPOSURE',
model_code: 'CREDIT_EXPOSURE',
model_name: '授信敞口监测模型',
primary_key_fields: ['institution_code', 'reporting_period'],
model_state: 'enabled',
model_apply_state: 'applied',
model_risk_level: 'medium',
model_business_type: '13',
model_business_type_name: '合规分析',
source_model_updated_at: '2026-06-30 08:00:00',
},
];
const MOCK_BATCHES = {
AML_CUSTOMER_RISK: [
{ batch_code: '2026-06', batch_name: '2026 年 6 月批次' },
{ batch_code: '2026-05', batch_name: '2026 年 5 月批次' },
430: [
{ source_model_id: '430', batch_code: '690', batch_name: '2026 年 6 月批次', source_batch_date: '2026-06-30 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_430', source_model_data_count: 5, source_unchecked_count: 5, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '430', batch_code: '652', batch_name: '2026 年 5 月批次', source_batch_date: '2026-05-31 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_430', source_model_data_count: 5, source_unchecked_count: 5, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '430', batch_code: '590', batch_name: '2025 年 12 月批次', source_batch_date: '2025-12-31 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_430', source_model_data_count: 5, source_unchecked_count: 5, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
],
TRANSACTION_ALERT: [
{ batch_code: '2026-W29', batch_name: '2026 年第 29 周' },
{ batch_code: '2026-W28', batch_name: '2026 年第 28 周' },
412: [
{ source_model_id: '412', batch_code: '670', batch_name: '2026 年第 29 周', source_batch_date: '2026-07-20 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_412', source_model_data_count: 7, source_unchecked_count: 7, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '412', batch_code: '669', batch_name: '2026 年第 28 周', source_batch_date: '2026-07-13 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_412', source_model_data_count: 7, source_unchecked_count: 7, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '412', batch_code: '570', batch_name: '2025 年第 52 周', source_batch_date: '2025-12-29 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_412', source_model_data_count: 7, source_unchecked_count: 7, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
],
CREDIT_EXPOSURE: [
{ batch_code: '2026-Q2', batch_name: '2026 年第二季度' },
{ batch_code: '2026-Q1', batch_name: '2026 年第一季度' },
411: [
{ source_model_id: '411', batch_code: '630', batch_name: '2026 年第二季度', source_batch_date: '2026-06-30 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_411', source_model_data_count: 4, source_unchecked_count: 4, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '411', batch_code: '629', batch_name: '2026 年第一季度', source_batch_date: '2026-03-31 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_411', source_model_data_count: 4, source_unchecked_count: 4, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
{ source_model_id: '411', batch_code: '530', batch_name: '2025 年第四季度', source_batch_date: '2025-12-31 08:00:00', data_table_name: 'ANALYZE_MODEL_DATA_MX_411', source_model_data_count: 4, source_unchecked_count: 4, source_unsubmitted_count: 0, source_reviewed_count: 0, remark: '' },
],
};
......@@ -41,16 +65,16 @@ const DATA_BUILDERS = {
{ name: 'risk_score', label: '风险分值', type: 'number' },
{ name: 'is_focus_customer', label: '是否重点客户', type: 'boolean' },
];
const month = batchCode === '2026-05' ? '05' : '06';
const period = batchCode === '590' ? { year: '2025', month: '12' } : batchCode === '652' ? { year: '2026', month: '05' } : { year: '2026', month: '06' };
const names = ['华远实业', '海川商贸', '恒信科技', '嘉禾供应链', '远景投资'];
return {
columns,
rows: names.map((name, index) => ({
customer_id: `C${String(index + 1).padStart(4, '0')}`,
risk_date: `2026-${month}-${String(index + 3).padStart(2, '0')}T00:00:00.000Z`,
risk_date: `${period.year}-${period.month}-${String(index + 3).padStart(2, '0')}T00:00:00.000Z`,
customer_name: name,
risk_level: index < 2 ? '高' : index < 4 ? '中' : '低',
risk_score: 91 - index * 9 + (month === '06' ? 2 : 0),
risk_score: 91 - index * 9 + (period.month === '06' ? 2 : 0),
is_focus_customer: index < 2,
})),
};
......@@ -63,14 +87,18 @@ const DATA_BUILDERS = {
{ name: 'transaction_time', label: '交易时间', type: 'date' },
{ name: 'alert_reason', label: '预警原因', type: 'text' },
];
const week = batchCode.endsWith('28') ? 28 : 29;
const period = batchCode === '570'
? { year: '2025', week: 52, startDay: 22 }
: batchCode === '669'
? { year: '2026', week: 28, startDay: 6 }
: { year: '2026', week: 29, startDay: 13 };
return {
columns,
rows: Array.from({ length: 7 }, (_, index) => ({
alert_id: `AL${week}${String(index + 1).padStart(3, '0')}`,
alert_id: `AL${period.week}${String(index + 1).padStart(3, '0')}`,
account_name: `监测账户 ${index + 1}`,
transaction_amount: 180000 + index * 73500,
transaction_time: `2026-07-${String((week === 29 ? 13 : 6) + index).padStart(2, '0')}T0${index % 9}:30:00.000Z`,
transaction_time: `${period.year}-${period.year === '2025' ? '12' : '07'}-${String(period.startDay + index).padStart(2, '0')}T0${index % 9}:30:00.000Z`,
alert_reason: index % 2 === 0 ? '短期高频大额交易' : '交易对手集中度异常',
})),
};
......@@ -84,7 +112,7 @@ const DATA_BUILDERS = {
{ name: 'used_amount', label: '已用额度', type: 'number' },
{ name: 'over_limit', label: '是否超限', type: 'boolean' },
];
const multiplier = batchCode === '2026-Q1' ? 0.94 : 1;
const multiplier = batchCode === '629' ? 0.94 : 1;
return {
columns,
rows: ['总行营业部', '华东分行', '华南分行', '西部中心'].map((name, index) => {
......@@ -112,19 +140,20 @@ export async function listSourceModels() {
return MOCK_MODELS.map((item) => ({ ...item }));
}
export async function listSourceBatches(modelName) {
export async function listSourceBatches(sourceModel) {
await wait();
const model = MOCK_MODELS.find((item) => item.model_name === modelName);
return (MOCK_BATCHES[model?.model_code] || []).map((item) => ({ ...item }));
return (MOCK_BATCHES[sourceModel?.source_model_id] || []).map((item) => ({
...item,
source_model_name: sourceModel.source_model_name || sourceModel.model_code,
}));
}
export async function loadSourceBatchData(modelName, batchCode) {
export async function loadSourceBatchData(sourceModel, sourceBatch) {
await wait(420);
const model = MOCK_MODELS.find((item) => item.model_name === modelName);
const builder = DATA_BUILDERS[model?.model_code];
const builder = DATA_BUILDERS[sourceModel?.model_code];
if (!builder) {
throw new Error('未找到所选模型的 Mock 数据。');
}
return builder(batchCode);
return builder(sourceBatch?.batch_code);
}
......@@ -98,7 +98,7 @@ export default function ModelOverview() {
{failedBatches.length ? (
<NoticeBanner tone="error">
有 {failedBatches.length} 个批次需要处理。进入对应模型批次后重新同步,系统会按组合主键继续增量写入。
有 {failedBatches.length} 个批次同步异常。请进入对应模型批次查看错误摘要;一次性同步批次不允许重试。
</NoticeBanner>
) : null}
......
This diff is collapsed.
......@@ -22,27 +22,37 @@
## 3. 外部数据源契约
本期外部来源使用 Mock 适配器,UI 仅依赖以下方法:
当前外部来源仍使用 Mock 适配器,但返回结构已映射到 `ANALYZE_MXVIEW_INFO` 和 `ANALYZE_MODEL_RUN_BATCH_INFO`。UI 仅依赖以下方法,真实 Oracle 适配器接入时不得修改页面调用:
```ts
listSourceModels(): Promise<Array<{
source_model_id: string;
source_model_name: string;
model_code: string;
model_name: string;
primary_key_fields: string[];
}>>;
listSourceBatches(modelName: string): Promise<Array<{
batch_code: string;
listSourceBatches(sourceModel: SourceModel): Promise<Array<{
source_model_id: string;
source_model_name: string;
batch_code: string; // MODEL_RUN_BATCH_ID
batch_name: string;
source_batch_date: string;
data_table_name: string;
source_model_data_count: number;
}>>;
loadSourceBatchData(modelName: string, batchCode: string): Promise<{
loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise<{
columns: Array<{ name: string; label: string; type: 'text' | 'number' | 'date' | 'boolean' | 'object' }>;
rows: Array<Record<string, unknown>>;
}>;
```
未来真实后台接口到位时只替换适配器,不修改页面和本地持久化服务。
来源映射规则:模型稳定键为 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID`;批次稳定键为 `ANALYZE_MODEL_RUN_BATCH_INFO.MODEL_RUN_BATCH_ID`,直接映射到本地 `batch_code`;结果表来自 `MODEL_DATA_TABLE_NAME`。结果表查询必须先对白名单表名做校验,再使用绑定参数按 `MODEL_RUN_BATCH_ID` 过滤,不能整表读取。
`ANALYZE_MODEL_DATA_MX_*` 结果行不要求业务主键。每个批次只允许同步一次,结果行按来源读取顺序记录 `source_row_number` 并纯新增,不进行去重或更新。
未来真实后台接口到位时只替换适配器,不修改页面和本地持久化服务。仓库目前没有 Oracle Connector 的调用协议,接入前必须补充接口 URL、请求体、分页和字段类型响应定义。
## 4. 核心流程
......@@ -53,31 +63,34 @@ loadSourceBatchData(modelName: string, batchCode: string): Promise<{
3. 缺失 Subject 时调用 Subject 创建接口。
4. 初始化失败时显示明确错误,页面不得使用 localStorage 或演示 Record 冒充真实数据。
### 4.2 同步单个模型批次
1. 用户在“全部模型”点击“同步模型数据”,或在某模型批次页点击“同步批次”。
2. 选择一个外部模型;模型接口同时提供组合主键字段。
3. 系统按模型名称加载批次列表,并与本地批次比对:`success` 批次显示“已同步”且不可选择,`partial/failed` 批次仍可重试。
4. 系统获取 `columns + rows`,在写入前完成:
### 4.2 批量同步模型批次
1. 用户在“全部模型”点击“同步模型数据”;某模型批次页仍保留当前模型的单批次同步入口。
2. 系统加载全部外部模型及其批次,同时读取本地所有批次;模型继续使用 `MODEL_NAME + ID` 识别。
3. 来源批次按 `source_batch_date` 的年份归类,默认仅展示当前年份;用户必须主动切换年份才能查看历史批次,系统不自动勾选任何批次。
4. 系统按 `MODEL_RUN_BATCH_ID` 与本地 `batch_code` 比对;本地已经存在的批次,无论 `syncing/success/partial/failed` 状态,均显示“已同步”、禁用复选框且不计入可选数量。
5. 用户可以跨模型逐项勾选批次,也可以“一键全选”当前年份内所有未同步批次;切换年份时保留已经勾选的批次,但全选不跨年份生效。
6. 提交后系统按选择顺序逐批同步;单个批次异常不阻断后续批次,结束后汇总成功批次数、异常批次数、新增数据量和失败数据量。
7. 每个批次获取 `columns + rows` 后,在写入前完成:
- 来源模型 ID、跑批 ID、模型归属和动态表名检查
- 来源声明数量与实际读取数量检查
- 字段别名、重复字段和类型检查
- 组合主键字段存在性检查
- 主键空值检查
- 批次内重复主键检查
5. 本地没有模型时创建 `model_monitor_model`,并生成稳定动态数据 Subject 名称。
6. 自动创建或扩展模型数据 Subject。只允许增加字段;类型冲突立即停止。
7. 按“模型 ID + 批次编码”查找批次;不存在则创建,`partial/failed` 批次更新为 `syncing`,`success` 批次直接拒绝重复同步。
8. 读取当前批次已有记录,以 `source_record_key` 建立索引。
9. 每 20 条分块写入:已有主键更新,新主键新增,本次未返回的旧记录保留。
10. 同步完成后更新批次数量和状态,并更新模型最近同步状态。
- 所有数值、日期、布尔和对象字段的规范化检查
8. 本地没有模型时按 `MODEL_NAME + ID` 创建稳定 `model_monitor_model` Record,并生成稳定动态数据 Subject 名称。
9. 自动创建或扩展模型数据 Subject。只允许增加字段;类型冲突立即停止。
10. 按 `MODEL_RUN_BATCH_ID` 查找批次;任何状态的本地批次一旦存在都拒绝重复同步,不再读取来源结果表。
11. 为来源行生成从 1 开始的 `source_row_number`。
12. 每 20 条分块纯新增写入,不查询已有结果行,也不执行更新;数据 Record name 由 `MODEL_RUN_BATCH_ID + source_row_number` 生成。
13. 同步完成后更新批次数量和状态,并更新模型最近同步状态;界面随即重新读取本地批次,把本轮已创建的批次锁定为不可选择。
### 4.3 同步状态与失败恢复
- `syncing`:同步执行中;开始时间 30 分钟内阻止再次发起。
- `success`:全部行写入成功;批次视为不可变,不允许再次同步。
- `syncing`:同步执行中;批次 Record 创建后永久阻止再次发起。
- `success`:全部行写入成功。
- `partial`:部分行写入失败,成功数据保留。
- `failed`:校验、对象创建或同步过程失败。
- 仅 `partial/failed` 批次允许重试;重试继续按组合主键新增或更新,不清空已有数据。
- 批次保留错误摘要、新增数、更新数、失败数、来源行数和本地总数。
- `partial/failed` 批次保留现场供排查,同样不允许重试,避免重复写入。
- 批次保留错误摘要、新增数、失败数、来源行数和本地总数;更新数固定为 0。
### 4.4 模型分组
......@@ -107,23 +120,26 @@ loadSourceBatchData(modelName: string, batchCode: string): Promise<{
1. 用户在批次数据页勾选 1-N 条记录。
2. 弹窗中再次确认本次实际下发记录;取消勾选的 ID 写入 `excluded_record_ids`。
3. 填写接收人、转发说明、一级审批人和终审人。
4. 创建 `model_monitor_forward_record`,并为实际下发记录创建 `model_monitor_forward_item`。
4. 创建 `model_monitor_forward_record`,并为实际下发记录创建 `model_monitor_forward_item`;发起时不写入尚未填写的是否属实、整改方案和预计整改完成时间字段。
5. 初始化 `editor_status = editing`、`approval_status = pending`、`final_approval_status = pending`。
6. 未结束流程中的 `excluded_record_ids` 在来源批次页暂时隐藏,流程结束后恢复展示。
6. 未结束流程中的 `excluded_record_ids` 在来源批次页暂时隐藏,终审通过后恢复展示。
### 4.8 填写与两级审批
1. 接收人在“我参与的”打开批次数据。
2. 对每条下发记录填写是否属实、整改方案、预计整改完成时间。
3. 可多次保存;点击“完成填写”前必须校验全部三项。
3. 可多次保存;三个回填字段在 Subject 层为可选,前端仍显示必填标记,并在点击“完成填写”前校验全部三项。
4. 完成后写入完成人和时间,行级数据锁定。
5. 一级审批人通过或驳回;只有一级通过后终审人才可操作。
6. 任一审批驳回后流程结束;终审通过后状态为已最终审批。
6. 任一审批驳回后,填写状态恢复为 `editing`,清空上次完成人和完成时间,接收人可修改原回填内容。
7. 接收人重新完成填写时,`approval_status` 和 `final_approval_status` 均重置为 `pending`,流程从一级审批重新开始。
8. 终审通过后状态为已最终审批,流程结束。
## 5. 页面状态与异常处理
- 所有接口必须展示 loading、success、error 三种状态。
- 同步、保存、审批期间禁用重复提交按钮。
- 批量同步期间禁止关闭弹窗;单批错误在结果区逐项展示,其余已选批次继续执行。
- 写入失败时保留当前选择和可恢复信息。
- 模型/批次/数据不存在时显示空状态或加载错误,不得白屏。
- 本期不提供模型或批次删除,也不限制同步和分组维护权限。
......
# 模型监控接口文档
**最后更新**:2026 年 7 月
**最后更新**:2026 年 8 月
## 1. 基础约定
......@@ -107,15 +107,17 @@
```json
{
"name": "model_data_<stable_hash>",
"title": "组合主键展示值",
"title": "第 1 条",
"subject": "model_monitor_data_example",
"content_type": "text",
"metadata": {
"customer_id": "C0001",
"source_model_id": "模型记录 ID",
"source_batch_id": "批次记录 ID",
"source_batch_code": "2026-06",
"source_record_key": "sha256",
"source_model_id": "本地模型 Record ID",
"source_external_model_id": "ANALYZE_MXVIEW_INFO.ID",
"source_batch_id": "本地批次 Record ID",
"source_batch_code": "MODEL_RUN_BATCH_ID 的字符串形式",
"source_data_table_name": "ANALYZE_MODEL_DATA_MX_430",
"source_row_number": 1,
"source_synced_at": "2026-07-23T06:00:00.000Z"
}
}
......@@ -125,7 +127,7 @@
`PUT /record/:name`
请求体使用完整 Record payload。同步命中已有 `source_record_key` 时调用该接口更新,不删除本次来源未返回的旧记录。
请求体使用完整 Record payload。当前用于更新模型、批次、隐藏状态和审批数据;一次性同步的模型结果行不调用更新接口。
### 3.6 删除 Record
......@@ -154,23 +156,39 @@
转发接收人、一级审批人和终审人必须远程查询,不使用静态用户选项。
## 5. 外部来源 Mock 契约
## 5. 外部来源适配契约
本期不调用数据库或真实外部 HTTP 接口。`ModelMonitorMockService` 提供以下异步方法:
当前仓库尚未提供 Oracle Connector 的请求体和响应体协议,因此仍由 `ModelMonitorMockService` 提供数据,但 Mock 已按 `ANALYZE_MXVIEW_INFO` 和 `ANALYZE_MODEL_RUN_BATCH_INFO` 的字段语义返回。真实适配器接入后必须保持以下签名,页面和 Record 同步逻辑不直接拼 SQL:
```ts
listSourceModels(): Promise<Array<{
source_model_id: string; // ANALYZE_MXVIEW_INFO.ID
source_model_name: string; // MODEL_NAME,来源技术名称
model_code: string;
model_name: string;
primary_key_fields: string[];
model_name: string; // MODEL_ZH_NAME,空时回退 MODEL_NAME
source_model_updated_at?: string;
model_state?: string;
model_apply_state?: string;
model_risk_level?: string;
model_business_type?: string;
model_business_type_name?: string;
}>>;
listSourceBatches(modelName: string): Promise<Array<{
batch_code: string;
listSourceBatches(sourceModel: SourceModel): Promise<Array<{
source_model_id: string; // MODEL_ID
source_model_name: string; // MODEL_NAME
batch_code: string; // MODEL_RUN_BATCH_ID
batch_name: string;
source_batch_date: string; // MODEL_RUN_BATCH_DATE
data_table_name: string; // MODEL_DATA_TABLE_NAME
remark: string;
source_model_data_count: number;
source_unchecked_count: number;
source_unsubmitted_count: number;
source_reviewed_count: number;
}>>;
loadSourceBatchData(modelName: string, batchCode: string): Promise<{
loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise<{
columns: Array<{
name: string;
label: string;
......@@ -182,21 +200,34 @@ loadSourceBatchData(modelName: string, batchCode: string): Promise<{
约束:
- `model_code` 为外部模型稳定唯一键。
- `model_name` 用于加载批次。
- `primary_key_fields` 至少包含一个字段,并且字段必须存在于批次 `columns`。
- `batch_code` 与本地模型 ID 组成批次唯一键。
- 本地批次状态为 `success` 时,数据源适配层返回的对应批次必须在界面禁用,service 层同时拒绝重复同步。
- `partial/failed` 批次允许重试,并继续按组合主键处理已有记录。
- 模型稳定唯一键为 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID`;本地分别保存为 `model_code + source_model_id`。
- `batch_code` 直接保存 `ANALYZE_MODEL_RUN_BATCH_INFO.MODEL_RUN_BATCH_ID`,不再增加重复的来源跑批 ID 字段。
- 批次的 `MODEL_NAME + MODEL_ID` 必须等于所选模型主键。
- `MODEL_DATA_TABLE_NAME` 只能来自批次表,并必须匹配 Oracle 非引号标识符白名单;不得接受页面输入的任意表名。
- 动态表名不能作为绑定参数,后端必须先校验白名单再拼接;批次 ID 必须使用绑定参数查询。
- 结果表必须按 `MODEL_RUN_BATCH_ID` 过滤,禁止只按表名读取整张模型结果表。
- `source_model_data_count` 存在时必须与实际读取行数一致,否则停止写入。
- 本地一旦存在相同 `batch_code` 的批次 Record,无论状态为何,界面和 service 层都必须拒绝再次同步。
- `columns[].name` 必须匹配 `^[a-z][a-z0-9_]*$` 且不可重复。
- 日期在写入 Record 前转换为 ISO 8601。
- 空组合主键、重复组合主键或字段类型冲突必须在写入前报错。
- 非法字段值或字段类型冲突必须在写入前报错。
- 结果行只执行新增,不读取已有结果行,也不执行更新;`source_row_number` 保存本次来源读取顺序。
- 模型、批次和数据行 Record name 分别由 `MODEL_NAME + ID`、`MODEL_RUN_BATCH_ID`、`MODEL_RUN_BATCH_ID + source_row_number` 生成稳定哈希。
真实后台接口就绪后,应在该适配层内完成请求/响应转换,保持上述方法签名不变。
### 5.1 Oracle 字段建议
现有字段已经能够满足一次性同步的唯一性要求:模型使用 `MODEL_NAME + ID`,批次使用 `MODEL_RUN_BATCH_ID`。`ANALYZE_MODEL_DATA_MX_*` 每个批次只同步一次且只新增,因此不要求增加结果行主键或主键配置字段。
建议为 `ANALYZE_MODEL_RUN_BATCH_INFO` 增加:
- `RUN_BATCH_STATE VARCHAR2(30)`:只允许同步已成功完成的来源批次。
- `ERROR_MESSAGE VARCHAR2(4000)`:保留来源跑批失败原因。
同时应把 `MODEL_RUN_BATCH_ID` 设为主键,把 `MODEL_ID`、`MODEL_DATA_TABLE_NAME` 设为非空,并为 `(MODEL_ID, MODEL_RUN_BATCH_DATE)` 建索引。现有日期字段均为 `VARCHAR2`,新字段优先使用 `TIMESTAMP(3)`;存量字段改型需单独迁移,不应由前端执行。
## 6. 错误处理
- Subject 或 Record 写入失败不得回退 localStorage。
- 同步部分失败时保留已成功记录,并把批次标记为 `partial`。
- 同步校验失败时不得开始写入动态数据 Record。
- 页面必须分别呈现 loading、success、error 状态,并保留可重试入口。
- 页面必须分别呈现 loading、success、error 状态;已创建批次不提供重试入口。
......@@ -49,14 +49,20 @@ type Record = {
| 标签 | 别名 | 类型 | 多值 | 必填 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 模型编码 | `model_code` | text | 否 | 是 | 外部模型唯一编码,本地不可编辑 |
| 来源模型 ID | `source_model_id` | text | 否 | 是 | `ANALYZE_MXVIEW_INFO.ID`,与 `model_code` 组成同步唯一键 |
| 模型编码 | `model_code` | text | 否 | 是 | `MODEL_NAME`,与来源模型 ID 组成同步唯一键 |
| 模型名称 | `model_name` | text | 否 | 是 | 外部模型显示名称,本地不可编辑 |
| 来源模型数据更新时间 | `source_model_updated_at` | date | 否 | 否 | `MODEL_DATA_UPDATE_TIME` 转 ISO 8601 |
| 模型状态 | `model_state` | text | 否 | 否 | `MODEL_STATE` |
| 模型应用状态 | `model_apply_state` | text | 否 | 否 | `MODEL_APPLY_STATE` |
| 模型风险等级 | `model_risk_level` | text | 否 | 否 | `MODEL_RISK_LEVEL` |
| 模型业务类型 | `model_business_type` | text | 否 | 否 | `MODEL_BUSINESS_TYPE` |
| 模型业务类型名称 | `model_business_type_name` | text | 否 | 否 | `MODEL_BUSINESS_TYPE_NAME` |
| 模型说明 | `description` | long_text | 否 | 否 | 本地归档说明 |
| 所属分组 ID | `group_id` | text | 否 | 否 | 关联模型分组 ID |
| 所属分组名称 | `group_name` | text | 否 | 否 | 冗余展示字段 |
| 模型标签 | `tags` | text | 是 | 否 | 本地检索标签 |
| 数据对象 | `data_subject_name` | text | 否 | 是 | 自动创建的动态 Subject 名称 |
| 组合主键字段 | `primary_key_fields` | text | 是 | 是 | 来源模型接口提供,首次同步后锁定 |
| 最近同步状态 | `last_sync_status` | select | 否 | 否 | `syncing/success/partial/failed` |
| 最近同步时间 | `last_synced_at` | date | 否 | 否 | ISO 8601 |
| 创建人 ID | `created_by_user_id` | text | 否 | 否 | 首次同步用户 ID |
......@@ -69,16 +75,22 @@ type Record = {
| 模型 ID | `model_id` | text | 否 | 是 | 仅保存模型 Record ID |
| 模型编码 | `model_code` | text | 否 | 是 | 冗余展示字段 |
| 模型名称 | `model_name` | text | 否 | 是 | 冗余展示字段 |
| 批次编码 | `batch_code` | text | 否 | 是 | 与模型 ID 组成批次唯一键 |
| 批次编码 | `batch_code` | text | 否 | 是 | 直接保存 `MODEL_RUN_BATCH_ID`,来源批次唯一键 |
| 批次名称 | `batch_name` | text | 否 | 是 | 外部批次显示名称 |
| 来源跑批时间 | `source_batch_date` | date | 否 | 否 | `MODEL_RUN_BATCH_DATE` 转 ISO 8601 |
| 来源数据表 | `source_data_table_name` | text | 否 | 是 | `MODEL_DATA_TABLE_NAME`,只允许白名单标识符 |
| 来源备注 | `source_remark` | long_text | 否 | 否 | `REMARK` |
| 来源未检查数量 | `source_unchecked_count` | number | 否 | 否 | `UNCHECKED_CNT` |
| 来源未提交数量 | `source_unsubmitted_count` | number | 否 | 否 | `UNSUBMITTED_CNT` |
| 来源已复核数量 | `source_reviewed_count` | number | 否 | 否 | `REVIEWED_CNT` |
| 数据对象 | `data_subject_name` | text | 否 | 是 | 当前模型动态 Subject |
| 字段快照 | `column_schema` | object | 是 | 否 | 当前批次最近同步的 `{name,label,type}` 列表 |
| 隐藏记录 ID | `hidden_record_ids` | text | 是 | 否 | 批次级手动隐藏的稳定 Record name |
| 同步状态 | `sync_status` | select | 否 | 是 | `syncing/success/partial/failed` |
| 源数据数量 | `source_row_count` | number | 否 | 否 | 最近一次来源响应行数 |
| 本地数据数量 | `total_record_count` | number | 否 | 否 | 增量同步后批次保留总数 |
| 新增数量 | `inserted_count` | number | 否 | 否 | 最近同步新增数 |
| 更新数量 | `updated_count` | number | 否 | 否 | 最近同步更新数 |
| 源数据数量 | `source_row_count` | number | 否 | 否 | `MODEL_DATA_CNT` 与实际读取数量校验后的行数 |
| 本地数据数量 | `total_record_count` | number | 否 | 否 | 本次导入后保存成功的总数 |
| 新增数量 | `inserted_count` | number | 否 | 否 | 本次同步新增数 |
| 更新数量 | `updated_count` | number | 否 | 否 | 兼容字段,一次性同步固定为 0 |
| 失败数量 | `failed_count` | number | 否 | 否 | 最近同步失败数 |
| 同步开始时间 | `sync_started_at` | date | 否 | 否 | 用于前端防重和超时判断 |
| 同步完成时间 | `synced_at` | date | 否 | 否 | ISO 8601 |
......@@ -110,7 +122,7 @@ type Record = {
| 终审人 ID/名称 | `final_approver` / `final_approver_name` | text | 否 | 是/否 |
| 发起人 ID/名称 | `initiator_id` / `initiator_name` | text | 否 | 否 |
状态选项由字段 `settings.options` 提供:填写状态为 `editing/completed`,审批状态为 `pending/approved/rejected`。
状态选项由字段 `settings.options` 提供:填写状态为 `editing/completed`,审批状态为 `pending/approved/rejected`。任一审批环节驳回后将 `editor_status` 重置为 `editing`;接收人重新完成填写时,两级审批状态均重置为 `pending`。
### 2.5 `model_monitor_forward_item`
......@@ -122,37 +134,42 @@ type Record = {
| 批次 ID/名称 | `batch_id` / `batch_name` | text | 否 | 是/否 |
| 数据对象 | `data_subject_name` | text | 否 | 是 |
| 源记录 ID/标题 | `source_record_id` / `source_record_title` | text | 否 | 是/否 |
| 是否属实 | `truth_status` | select | 否 | 是 |
| 整改方案 | `rectification_plan` | long_text | 否 | 是 |
| 预计整改完成时间 | `expected_rectification_completed_at` | date | 否 | 是 |
| 是否属实 | `truth_status` | select | 否 | 否 |
| 整改方案 | `rectification_plan` | long_text | 否 | 否 |
| 预计整改完成时间 | `expected_rectification_completed_at` | date | 否 | 否 |
| 填写人 ID/名称 | `filled_by_id` / `filled_by_name` | text | 否 | 否 |
| 填写时间 | `filled_at` | date | 否 | 否 |
| 备注 | `remark` | long_text | 否 | 否 |
`truth_status` 选项必须来自 `settings.options`,当前值为 `truthful/untruthful`。
上述三个回填字段在 Subject 层保持可选,以允许转发发起时先创建空白明细并分次保存。未填写时不向 `metadata` 写入这三个字段,不使用空字符串占位;前端界面仍标记为必填,并在“完成填写”时校验三项均已填写。
## 3. 动态模型数据 Subject
每个模型首次同步时自动创建一个稳定的 `model_monitor_data_<model_code>_<hash>` Subject。模型改名或后续批次同步不得改变该 Subject 名称。
每个模型首次同步时按 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID` 自动创建稳定的动态数据 Subject。后续批次继续使用同一 Subject。
固定系统字段:
| 标签 | 别名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| 来源模型 ID | `source_model_id` | text | 是 | 模型 Record ID |
| 来源批次 ID | `source_batch_id` | text | 是 | 批次 Record ID |
| 来源批次编码 | `source_batch_code` | text | 是 | 外部批次编码 |
| 来源记录键 | `source_record_key` | text | 是 | 组合主键规范化后 SHA-256 |
| 本地模型 Record ID | `source_model_id` | text | 是 | 模型 Record ID,兼容既有查询字段 |
| 来源模型 ID | `source_external_model_id` | text | 是 | `ANALYZE_MXVIEW_INFO.ID` |
| 本地批次 Record ID | `source_batch_id` | text | 是 | 批次 Record ID,批次数据查询条件 |
| 来源批次编码 | `source_batch_code` | text | 是 | `MODEL_RUN_BATCH_ID` |
| 来源数据表 | `source_data_table_name` | text | 是 | `MODEL_DATA_TABLE_NAME` |
| 来源行序号 | `source_row_number` | number | 是 | 本次来源读取的 1 基序号,不作为业务主键 |
| 同步时间 | `source_synced_at` | date | 是 | 最近写入时间 |
业务字段来自数据接口的 `columns`,仅支持 `text/number/date/boolean/object`。新字段允许自动添加;已有字段不得自动删除,同名字段类型变化必须阻止同步。
数据 Record `name` 由模型、批次和 `source_record_key` 生成稳定哈希。增量同步命中主键时更新,未命中时新增,本次未返回的历史记录继续保留。
结果行不配置业务主键。数据 Record `name` 由 `MODEL_RUN_BATCH_ID + source_row_number` 生成稳定哈希,只执行一次性新增,不查询或更新已有结果行。
## 4. 当前约束
- 模型和批次不提供删除。
- 模型仅由同步自动创建,模型编码、名称和组合主键不可本地修改。
- 模型仅由同步自动创建,来源模型 ID、模型编码和名称不可本地修改。
- `hidden_record_ids` 为批次级共享状态,与转发的 `excluded_record_ids` 分开维护。
- 固定 Subject 在开发阶段由前端检查并创建;正式环境应由部署流程预建。
- 已存在的 Subject 自动补充新字段时先按可选字段添加,以兼容历史 Record;新同步数据仍由 service 层执行必填校验。
- Subject/Record 写入失败必须明确报错,不得回退 localStorage。
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