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}
......
......@@ -9,6 +9,7 @@ import {
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { UserContext } from '@/wrapper/Auth';
import {
ArrowPathIcon,
......@@ -49,6 +50,26 @@ import {
const ROOT_GROUP_ID = '__all__';
function getSourceModelKey(model = {}) {
return JSON.stringify([
String(model.source_model_name || model.model_code || ''),
String(model.source_model_id || ''),
]);
}
function getBatchYear(value) {
const text = String(value || '');
const yearMatch = text.match(/\b(19|20)\d{2}\b/);
if (yearMatch) return yearMatch[0];
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? '日期未知' : String(parsed.getFullYear());
}
function getBatchTimestamp(value) {
const parsed = new Date(String(value || '').replace(' ', 'T'));
return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime();
}
function buildGroupTree(groups, parentId = '') {
return groups
.filter((group) => (group.parentGroupId || '') === parentId)
......@@ -114,14 +135,14 @@ export default function Models() {
const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false);
const [syncStep, setSyncStep] = useState(1);
const [sourceModels, setSourceModels] = useState([]);
const [sourceBatches, setSourceBatches] = useState([]);
const [sourceBatchEntries, setSourceBatchEntries] = useState([]);
const [syncedBatchCodes, setSyncedBatchCodes] = useState([]);
const [selectedModelCode, setSelectedModelCode] = useState('');
const [selectedBatchCode, setSelectedBatchCode] = useState('');
const [selectedBatchCodes, setSelectedBatchCodes] = useState([]);
const [activeBatchYear, setActiveBatchYear] = useState('');
const [syncLoading, setSyncLoading] = useState(false);
const [syncProgress, setSyncProgress] = useState({ completed: 0, total: 0 });
const [syncResult, setSyncResult] = useState(null);
const [syncDialogError, setSyncDialogError] = useState('');
async function refresh() {
const [modelList, groupList] = await Promise.all([loadModels(), loadModelGroups()]);
......@@ -238,67 +259,131 @@ export default function Models() {
setIsSyncDialogOpen(true);
setSyncStep(1);
setSyncLoading(true);
setSelectedModelCode('');
setSelectedBatchCode('');
setSourceBatches([]);
setSourceBatchEntries([]);
setSelectedBatchCodes([]);
setActiveBatchYear('');
setSyncedBatchCodes([]);
setSyncResult(null);
setSyncDialogError('');
setSyncProgress({ completed: 0, total: 0 });
try {
setSourceModels(await listSourceModels());
const [sourceModelList, localBatchList] = await Promise.all([listSourceModels(), loadModelBatches()]);
const batchLists = await Promise.all(sourceModelList.map(async (sourceModel) => {
const batches = await listSourceBatches(sourceModel);
return batches.map((sourceBatch) => ({
key: String(sourceBatch.batch_code),
year: getBatchYear(sourceBatch.source_batch_date),
sourceModel,
sourceBatch,
}));
}));
const entries = batchLists.flat().sort((left, right) => {
const dateOrder = getBatchTimestamp(right.sourceBatch.source_batch_date) - getBatchTimestamp(left.sourceBatch.source_batch_date);
return dateOrder || left.sourceModel.model_name.localeCompare(right.sourceModel.model_name, 'zh-CN');
});
const years = [...new Set(entries.map((entry) => entry.year).filter((year) => year !== '日期未知'))];
const currentYear = String(new Date().getFullYear());
setSourceBatchEntries(entries);
setSyncedBatchCodes(localBatchList.map((batch) => String(batch.batchCode)));
setActiveBatchYear(years.includes(currentYear) ? currentYear : years[0] || entries[0]?.year || '');
setSyncStep(2);
} catch (loadError) {
setError(loadError?.message || '外部模型列表加载失败。');
setSyncDialogError(loadError?.message || '外部模型与批次列表加载失败。');
} finally {
setSyncLoading(false);
}
}
async function handleSelectSourceModel(modelCode) {
setSelectedModelCode(modelCode);
setSelectedBatchCode('');
setSourceBatches([]);
if (!modelCode) return;
const model = sourceModels.find((item) => item.model_code === modelCode);
const localModel = models.find((item) => item.modelCode === modelCode);
setSyncLoading(true);
try {
const [batchList, localBatches] = await Promise.all([
listSourceBatches(model.model_name),
localModel ? loadModelBatches(localModel.id) : Promise.resolve([]),
]);
setSourceBatches(batchList);
setSyncedBatchCodes(
localBatches
.filter((batch) => batch.syncStatus === 'success')
.map((batch) => batch.batchCode),
);
setSyncStep(2);
} catch (loadError) {
setError(loadError?.message || '外部批次列表加载失败。');
} finally {
setSyncLoading(false);
}
const batchesByYear = useMemo(() => sourceBatchEntries.reduce((groups, entry) => {
groups[entry.year] = groups[entry.year] || [];
groups[entry.year].push(entry);
return groups;
}, {}), [sourceBatchEntries]);
const batchYears = useMemo(() => Object.keys(batchesByYear).sort((left, right) => {
if (left === '日期未知') return 1;
if (right === '日期未知') return -1;
return Number(right) - Number(left);
}), [batchesByYear]);
const activeYearEntries = batchesByYear[activeBatchYear] || [];
const activeYearSelectableEntries = activeYearEntries.filter((entry) => !syncedBatchCodes.includes(entry.key));
const allActiveYearSelected = activeYearSelectableEntries.length > 0
&& activeYearSelectableEntries.every((entry) => selectedBatchCodes.includes(entry.key));
const someActiveYearSelected = activeYearSelectableEntries.some((entry) => selectedBatchCodes.includes(entry.key));
const activeYearModelGroups = useMemo(() => {
const modelGroups = new Map();
activeYearEntries.forEach((entry) => {
const modelKey = getSourceModelKey(entry.sourceModel);
if (!modelGroups.has(modelKey)) modelGroups.set(modelKey, { sourceModel: entry.sourceModel, entries: [] });
modelGroups.get(modelKey).entries.push(entry);
});
return [...modelGroups.values()];
}, [activeYearEntries]);
function toggleBatch(entry, checked) {
if (syncedBatchCodes.includes(entry.key)) return;
setSelectedBatchCodes((current) => checked
? [...new Set([...current, entry.key])]
: current.filter((code) => code !== entry.key));
}
function toggleAllActiveYear(checked) {
const activeCodes = activeYearSelectableEntries.map((entry) => entry.key);
setSelectedBatchCodes((current) => checked
? [...new Set([...current, ...activeCodes])]
: current.filter((code) => !activeCodes.includes(code)));
}
async function handleSync() {
const sourceModel = sourceModels.find((item) => item.model_code === selectedModelCode);
const sourceBatch = sourceBatches.find((item) => item.batch_code === selectedBatchCode);
if (!sourceModel || !sourceBatch) return;
if (syncedBatchCodes.includes(sourceBatch.batch_code)) {
setError('该批次已经同步成功,不能再次同步。');
return;
}
const selectedEntries = sourceBatchEntries
.filter((entry) => selectedBatchCodes.includes(entry.key))
.filter((entry) => !syncedBatchCodes.includes(entry.key));
if (!selectedEntries.length) return;
setSyncLoading(true);
setSyncStep(3);
setSyncResult(null);
setSyncDialogError('');
setSyncProgress({ completed: 0, total: selectedEntries.length, currentBatch: '', currentRecordsCompleted: 0, currentRecordsTotal: 0 });
const completedCodes = [];
const failures = [];
let insertedCount = 0;
let failedRecordCount = 0;
try {
const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, setSyncProgress);
setSyncResult(result);
await refresh();
setNotice(`“${sourceModel.model_name} / ${sourceBatch.batch_name}”同步完成。`);
for (let index = 0; index < selectedEntries.length; index += 1) {
const { sourceModel, sourceBatch, key } = selectedEntries[index];
setSyncProgress((current) => ({ ...current, currentBatch: `${sourceModel.model_name} / ${sourceBatch.batch_name}`, currentRecordsCompleted: 0, currentRecordsTotal: 0 }));
try {
const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, (progress) => {
setSyncProgress((current) => ({ ...current, currentRecordsCompleted: progress.completed || 0, currentRecordsTotal: progress.total || 0 }));
});
completedCodes.push(key);
insertedCount += result.insertedCount || 0;
failedRecordCount += result.failedCount || 0;
if (result.failedCount) {
failures.push({ label: `${sourceModel.model_name} / ${sourceBatch.batch_name}`, message: `${result.failedCount} 条数据写入失败;该批次已锁定,不可再次同步。` });
}
} catch (syncError) {
failures.push({ label: `${sourceModel.model_name} / ${sourceBatch.batch_name}`, message: syncError?.message || '同步失败' });
}
setSyncProgress((current) => ({ ...current, completed: index + 1, currentRecordsCompleted: 0, currentRecordsTotal: 0 }));
}
setSyncedBatchCodes((current) => [...new Set([...current, ...completedCodes])]);
setSelectedBatchCodes((current) => current.filter((code) => !completedCodes.includes(code)));
setSyncResult({
totalBatchCount: selectedEntries.length,
completedBatchCount: selectedEntries.length - failures.length,
failedBatchCount: failures.length,
insertedCount,
failedRecordCount,
failures,
});
setNotice(`已完成 ${completedCodes.length}/${selectedEntries.length} 个批次同步,共新增 ${insertedCount} 条数据。`);
setError('');
const [, localBatchList] = await Promise.all([refresh(), loadModelBatches()]);
const nextSyncedCodes = localBatchList.map((batch) => String(batch.batchCode));
setSyncedBatchCodes(nextSyncedCodes);
setSelectedBatchCodes((current) => current.filter((code) => !nextSyncedCodes.includes(code)));
} catch (syncError) {
setError(syncError?.message || '模型批次同步失败。');
setSyncDialogError(syncError?.message || '同步结果刷新失败,请稍后重新打开页面确认。');
} finally {
setSyncLoading(false);
}
......@@ -337,9 +422,74 @@ export default function Models() {
<Dialog open={isGroupDialogOpen} onClose={setIsGroupDialogOpen} size="lg"><DialogTitle>{groupForm.recordName ? '编辑模型分组' : '新建模型分组'}</DialogTitle><DialogDescription>分组只用于整理模型,不影响模型数据和批次。</DialogDescription><DialogBody className="space-y-4"><div><FieldLabel required>分组名称</FieldLabel><Input value={groupForm.groupName} onChange={(event) => setGroupForm((current) => ({ ...current, groupName: event.target.value }))} /></div><div><FieldLabel>父分组</FieldLabel><Select value={groupForm.parentGroupId} onChange={(event) => setGroupForm((current) => ({ ...current, parentGroupId: event.target.value }))}><option value="">作为一级分组</option>{groupOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</Select></div></DialogBody><DialogActions><Button plain onClick={() => setIsGroupDialogOpen(false)}>取消</Button><Button color="blue" onClick={handleSaveGroup}>保存分组</Button></DialogActions></Dialog>
<Dialog open={isModelDialogOpen} onClose={setIsModelDialogOpen} size="lg"><DialogTitle>编辑模型归档信息</DialogTitle><DialogDescription>模型名称、编码和组合主键由外部模型列表提供,本地只维护说明、分组和标签。</DialogDescription><DialogBody className="space-y-4"><div className="grid gap-4 sm:grid-cols-2"><div><FieldLabel>模型名称</FieldLabel><Input value={editingModel?.modelName || ''} disabled /></div><div><FieldLabel>模型编码</FieldLabel><Input value={editingModel?.modelCode || ''} disabled /></div></div><div><FieldLabel>所属分组</FieldLabel><Select value={modelForm.groupId} onChange={(event) => setModelForm((current) => ({ ...current, groupId: event.target.value }))}><option value="">暂不分组</option>{groupOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</Select></div><div><FieldLabel>标签</FieldLabel><Input value={modelForm.tags} onChange={(event) => setModelForm((current) => ({ ...current, tags: event.target.value }))} placeholder="多个标签用逗号分隔" /></div><div><FieldLabel>模型说明</FieldLabel><Textarea rows={4} value={modelForm.description} onChange={(event) => setModelForm((current) => ({ ...current, description: event.target.value }))} /></div></DialogBody><DialogActions><Button plain onClick={() => setIsModelDialogOpen(false)}>取消</Button><Button color="blue" onClick={handleSaveModel}>保存</Button></DialogActions></Dialog>
<Dialog open={isModelDialogOpen} onClose={setIsModelDialogOpen} size="lg"><DialogTitle>编辑模型归档信息</DialogTitle><DialogDescription>模型名称、编码和来源模型 ID 由外部模型列表提供,本地只维护说明、分组和标签。</DialogDescription><DialogBody className="space-y-4"><div className="grid gap-4 sm:grid-cols-2"><div><FieldLabel>模型名称</FieldLabel><Input value={editingModel?.modelName || ''} disabled /></div><div><FieldLabel>模型编码</FieldLabel><Input value={editingModel?.modelCode || ''} disabled /></div></div><div><FieldLabel>所属分组</FieldLabel><Select value={modelForm.groupId} onChange={(event) => setModelForm((current) => ({ ...current, groupId: event.target.value }))}><option value="">暂不分组</option>{groupOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</Select></div><div><FieldLabel>标签</FieldLabel><Input value={modelForm.tags} onChange={(event) => setModelForm((current) => ({ ...current, tags: event.target.value }))} placeholder="多个标签用逗号分隔" /></div><div><FieldLabel>模型说明</FieldLabel><Textarea rows={4} value={modelForm.description} onChange={(event) => setModelForm((current) => ({ ...current, description: event.target.value }))} /></div></DialogBody><DialogActions><Button plain onClick={() => setIsModelDialogOpen(false)}>取消</Button><Button color="blue" onClick={handleSaveModel}>保存</Button></DialogActions></Dialog>
<Dialog open={isSyncDialogOpen} onClose={syncLoading ? () => {} : setIsSyncDialogOpen} size="4xl">
<DialogTitle>批量同步模型批次</DialogTitle>
<DialogDescription>批次按来源时间归入年份,默认展示当前年。全选只作用于当前年份;已经执行过同步的批次不可勾选。</DialogDescription>
<DialogBody className="space-y-5">
<ol className="grid grid-cols-3 gap-2" aria-label="同步步骤">
{['加载批次', '选择批次', '执行同步'].map((label, index) => <li key={label} className={`rounded-lg border px-2 py-2 text-center text-sm ${syncStep === index + 1 ? 'border-sky-300 bg-sky-50 font-semibold text-sky-800' : syncStep > index + 1 ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-zinc-200 text-zinc-500'}`}>{index + 1}. {label}</li>)}
</ol>
{syncDialogError ? <NoticeBanner tone="error">{syncDialogError}</NoticeBanner> : null}
{syncStep === 1 && syncLoading ? <NoticeBanner>正在加载全部来源模型、批次和本地同步状态...</NoticeBanner> : null}
{syncStep === 2 ? (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-[13rem_minmax(0,1fr)] sm:items-end">
<div>
<FieldLabel required>批次年份</FieldLabel>
<Select aria-label="批次年份" value={activeBatchYear} onChange={(event) => setActiveBatchYear(event.target.value)}>
{batchYears.map((year) => <option key={year} value={year}>{year === '日期未知' ? year : `${year} 年`}({batchesByYear[year].length} 个批次)</option>)}
</Select>
</div>
<label className={`flex min-h-11 items-center gap-3 rounded-lg border px-4 py-2 text-sm ${activeYearSelectableEntries.length ? 'cursor-pointer border-zinc-200 bg-white text-zinc-800 hover:bg-zinc-50' : 'cursor-not-allowed border-zinc-100 bg-zinc-50 text-zinc-400'}`}>
<Checkbox color="blue" checked={allActiveYearSelected} indeterminate={!allActiveYearSelected && someActiveYearSelected} disabled={!activeYearSelectableEntries.length} onChange={(checked) => toggleAllActiveYear(Boolean(checked))} />
<span className="font-medium">全选当前年份可同步批次</span>
<span className="ml-auto tabular-nums text-zinc-500">{activeYearSelectableEntries.length} 个可选</span>
</label>
</div>
{activeYearModelGroups.length ? (
<div className="max-h-[48vh] overflow-y-auto rounded-lg border border-zinc-200">
{activeYearModelGroups.map(({ sourceModel, entries }) => (
<section key={getSourceModelKey(sourceModel)} className="border-b border-zinc-200 last:border-b-0">
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-zinc-50 px-4 py-2.5">
<div className="min-w-0"><div className="truncate text-sm font-semibold text-zinc-900">{sourceModel.model_name}</div><div className="truncate text-xs text-zinc-500">{sourceModel.model_code}</div></div>
<span className="shrink-0 text-xs tabular-nums text-zinc-500">{entries.length} 个批次</span>
</div>
<div className="divide-y divide-zinc-100">
{entries.map((entry) => {
const synced = syncedBatchCodes.includes(entry.key);
return (
<label key={entry.key} className={`grid min-h-14 grid-cols-[1.25rem_minmax(0,1fr)_auto] items-center gap-3 px-4 py-2.5 ${synced ? 'cursor-not-allowed bg-zinc-50 text-zinc-400' : 'cursor-pointer text-zinc-800 hover:bg-sky-50/60'}`}>
<Checkbox color="blue" checked={!synced && selectedBatchCodes.includes(entry.key)} disabled={synced} onChange={(checked) => toggleBatch(entry, Boolean(checked))} aria-label={`选择 ${sourceModel.model_name} ${entry.sourceBatch.batch_name}`} />
<span className="min-w-0"><span className="block truncate text-sm font-medium">{entry.sourceBatch.batch_name}</span><span className="mt-0.5 block text-xs tabular-nums text-zinc-500">{formatDateTime(entry.sourceBatch.source_batch_date)} · {entry.sourceBatch.batch_code}</span></span>
{synced ? <Tag tone="emerald">已同步</Tag> : <span className="text-xs tabular-nums text-zinc-500">{Number(entry.sourceBatch.source_model_data_count || 0).toLocaleString('zh-CN')} 条</span>}
</label>
);
})}
</div>
</section>
))}
</div>
) : <EmptyCollection title="该年份没有来源批次" description="请选择其他年份查看可同步批次。" />}
<div className="text-sm text-zinc-600">已选择 <span className="font-semibold tabular-nums text-zinc-950">{selectedBatchCodes.length}</span> 个批次;切换年份不会清空已选项。</div>
</div>
) : null}
<Dialog open={isSyncDialogOpen} onClose={syncLoading ? () => {} : setIsSyncDialogOpen} size="2xl"><DialogTitle>同步模型批次</DialogTitle><DialogDescription>单次同步一个模型下的一个批次。已同步成功的批次不可再次同步,失败或部分成功的批次仍可重试。</DialogDescription><DialogBody className="space-y-5"><ol className="grid grid-cols-3 gap-2" aria-label="同步步骤">{['选择模型', '选择批次', '执行同步'].map((label, index) => <li key={label} className={`rounded-lg border px-3 py-2 text-center text-sm ${syncStep === index + 1 ? 'border-sky-300 bg-sky-50 font-semibold text-sky-800' : syncStep > index + 1 ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-zinc-200 text-zinc-500'}`}>{index + 1}. {label}</li>)}</ol><div><FieldLabel required>外部模型</FieldLabel><Select value={selectedModelCode} disabled={syncLoading || syncStep === 3} onChange={(event) => handleSelectSourceModel(event.target.value)}><option value="">请选择模型</option>{sourceModels.map((model) => <option key={model.model_code} value={model.model_code}>{model.model_name}({model.model_code})</option>)}</Select>{selectedModelCode ? <div className="mt-2 text-xs text-zinc-500">组合主键:{sourceModels.find((item) => item.model_code === selectedModelCode)?.primary_key_fields.join(' + ')}</div> : null}</div>{syncStep >= 2 ? <div><FieldLabel required>外部批次</FieldLabel><Select value={selectedBatchCode} disabled={syncLoading || syncStep === 3} onChange={(event) => setSelectedBatchCode(event.target.value)}><option value="">请选择批次</option>{sourceBatches.map((batch) => { const synced = syncedBatchCodes.includes(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> : null}{syncStep === 3 ? <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4"><div className="flex items-center justify-between gap-4 text-sm"><span>{syncLoading ? '正在校验字段并写入 Record...' : syncResult ? '同步已完成' : '同步已停止'}</span><span className="tabular-nums">{syncProgress.total ? `${syncProgress.completed}/${syncProgress.total}` : ''}</span></div><div className="mt-3 h-2 overflow-hidden rounded-full bg-zinc-200"><div className="h-full bg-sky-600 transition-all" style={{ width: `${syncProgress.total ? Math.round(syncProgress.completed / syncProgress.total * 100) : syncLoading ? 10 : 0}%` }} /></div>{syncResult ? <div className="mt-3 grid grid-cols-3 gap-3 text-center text-sm"><div><div className="font-semibold text-zinc-900">{syncResult.insertedCount}</div><div className="text-xs text-zinc-500">新增</div></div><div><div className="font-semibold text-zinc-900">{syncResult.updatedCount}</div><div className="text-xs text-zinc-500">更新</div></div><div><div className="font-semibold text-zinc-900">{syncResult.failedCount}</div><div className="text-xs text-zinc-500">失败</div></div></div> : null}</div> : null}</DialogBody><DialogActions><Button plain disabled={syncLoading} onClick={() => setIsSyncDialogOpen(false)}>{syncResult ? '完成' : '取消'}</Button>{syncStep === 2 ? <Button color="blue" disabled={!selectedBatchCode || syncedBatchCodes.includes(selectedBatchCode) || syncLoading} onClick={handleSync}>开始同步</Button> : null}</DialogActions></Dialog>
{syncStep === 3 ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4">
<div className="flex items-start justify-between gap-4 text-sm"><div className="min-w-0"><div className="font-medium text-zinc-900">{syncLoading ? '正在逐批校验并写入 Record...' : '批量同步已结束'}</div>{syncProgress.currentBatch ? <div className="mt-1 truncate text-xs text-zinc-500">{syncProgress.currentBatch}{syncProgress.currentRecordsTotal ? ` · ${syncProgress.currentRecordsCompleted}/${syncProgress.currentRecordsTotal} 条` : ''}</div> : null}</div><span className="shrink-0 tabular-nums">{syncProgress.completed}/{syncProgress.total}</span></div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-zinc-200"><div className="h-full bg-sky-600 transition-all" style={{ width: `${syncProgress.total ? Math.round(syncProgress.completed / syncProgress.total * 100) : syncLoading ? 10 : 0}%` }} /></div>
{syncResult ? <div className="mt-4 grid grid-cols-2 gap-3 text-center text-sm sm:grid-cols-4"><div><div className="font-semibold text-zinc-900">{syncResult.completedBatchCount}</div><div className="text-xs text-zinc-500">完成批次</div></div><div><div className="font-semibold text-zinc-900">{syncResult.failedBatchCount}</div><div className="text-xs text-zinc-500">异常批次</div></div><div><div className="font-semibold text-zinc-900">{syncResult.insertedCount}</div><div className="text-xs text-zinc-500">新增数据</div></div><div><div className="font-semibold text-zinc-900">{syncResult.failedRecordCount}</div><div className="text-xs text-zinc-500">失败数据</div></div></div> : null}
{syncResult?.failures?.length ? <div className="mt-4 space-y-2 border-t border-zinc-200 pt-3">{syncResult.failures.map((failure) => <div key={failure.label} className="text-xs leading-5 text-rose-700"><span className="font-medium">{failure.label}:</span>{failure.message}</div>)}</div> : null}
</div>
) : null}
</DialogBody>
<DialogActions><Button plain disabled={syncLoading} onClick={() => setIsSyncDialogOpen(false)}>{syncResult ? '完成' : '取消'}</Button>{syncStep === 2 ? <Button color="blue" disabled={!selectedBatchCodes.length || syncLoading} onClick={handleSync}>同步所选 {selectedBatchCodes.length ? `(${selectedBatchCodes.length})` : ''}</Button> : null}</DialogActions>
</Dialog>
</div>
);
}
......@@ -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 三种状态。
- 同步、保存、审批期间禁用重复提交按钮。
- 批量同步期间禁止关闭弹窗;单批错误在结果区逐项展示,其余已选批次继续执行。
- 写入失败时保留当前选择和可恢复信息。
- 模型/批次/数据不存在时显示空状态或加载错误,不得白屏。
- 本期不提供模型或批次删除,也不限制同步和分组维护权限。
......
......@@ -48,6 +48,7 @@ export const FORWARD_ITEM_FILL_FIELDS = [
label: '是否属实',
type: 'select',
isRequired: true,
subjectRequired: false,
settings: { options: TRUTH_STATUS_OPTIONS },
},
{
......@@ -55,6 +56,7 @@ export const FORWARD_ITEM_FILL_FIELDS = [
label: '整改方案',
type: 'long_text',
isRequired: true,
subjectRequired: false,
settings: {},
},
{
......@@ -62,15 +64,18 @@ export const FORWARD_ITEM_FILL_FIELDS = [
label: '预计整改完成时间',
type: 'date',
isRequired: true,
subjectRequired: false,
settings: {},
},
];
const SYSTEM_DATA_FIELDS = [
{ name: 'source_model_id', label: '来源模型 ID', type: 'text', required: true },
{ name: 'source_batch_id', label: '来源批次 ID', type: 'text', required: true },
{ name: 'source_model_id', label: '本地模型 Record ID', type: 'text', required: true },
{ name: 'source_external_model_id', label: '来源模型 ID', type: 'text', required: true },
{ name: 'source_batch_id', label: '本地批次 Record ID', type: 'text', required: true },
{ name: 'source_batch_code', label: '来源批次编码', type: 'text', required: true },
{ name: 'source_record_key', label: '来源记录键', type: 'text', required: true },
{ name: 'source_data_table_name', label: '来源数据表', type: 'text', required: true },
{ name: 'source_row_number', label: '来源行序号', type: 'number', required: true },
{ name: 'source_synced_at', label: '同步时间', type: 'date', required: true },
];
......@@ -89,14 +94,20 @@ const FIXED_SUBJECTS = [
name: MODEL_SUBJECT,
title: '模型主档',
fields: [
{ name: 'source_model_id', label: '来源模型 ID', type: 'text', required: true },
{ name: 'model_code', label: '模型编码', type: 'text', required: true },
{ name: 'model_name', label: '模型名称', type: 'text', required: true },
{ name: 'source_model_updated_at', label: '来源模型数据更新时间', type: 'date' },
{ name: 'model_state', label: '模型状态', type: 'text' },
{ name: 'model_apply_state', label: '模型应用状态', type: 'text' },
{ name: 'model_risk_level', label: '模型风险等级', type: 'text' },
{ name: 'model_business_type', label: '模型业务类型', type: 'text' },
{ name: 'model_business_type_name', label: '模型业务类型名称', type: 'text' },
{ name: 'description', label: '模型说明', type: 'long_text' },
{ name: 'group_id', label: '所属分组 ID', type: 'text' },
{ name: 'group_name', label: '所属分组名称', type: 'text' },
{ name: 'tags', label: '模型标签', type: 'text', multiple: true },
{ name: 'data_subject_name', label: '数据对象', type: 'text', required: true },
{ name: 'primary_key_fields', label: '组合主键字段', type: 'text', multiple: true, required: true },
{ name: 'last_sync_status', label: '最近同步状态', type: 'select', options: SYNC_STATUS_OPTIONS },
{ name: 'last_synced_at', label: '最近同步时间', type: 'date' },
{ name: 'created_by_user_id', label: '创建人 ID', type: 'text' },
......@@ -112,6 +123,12 @@ const FIXED_SUBJECTS = [
{ name: 'model_name', label: '模型名称', type: 'text', required: true },
{ name: 'batch_code', label: '批次编码', type: 'text', required: true },
{ name: 'batch_name', label: '批次名称', type: 'text', required: true },
{ name: 'source_batch_date', label: '来源跑批时间', type: 'date' },
{ name: 'source_data_table_name', label: '来源数据表', type: 'text', required: true },
{ name: 'source_remark', label: '来源备注', type: 'long_text' },
{ name: 'source_unchecked_count', label: '来源未检查数量', type: 'number' },
{ name: 'source_unsubmitted_count', label: '来源未提交数量', type: 'number' },
{ name: 'source_reviewed_count', label: '来源已复核数量', type: 'number' },
{ name: 'data_subject_name', label: '数据对象', type: 'text', required: true },
{ name: 'column_schema', label: '字段快照', type: 'object' },
{ name: 'hidden_record_ids', label: '隐藏记录 ID', type: 'text', multiple: true },
......@@ -174,7 +191,7 @@ const FIXED_SUBJECTS = [
name: field.alias,
label: field.label,
type: field.type,
required: field.isRequired,
required: field.subjectRequired,
options: field.settings?.options,
})),
{ name: 'filled_by_id', label: '填写人 ID', type: 'text' },
......@@ -311,14 +328,20 @@ function normalizeModel(record = {}) {
return {
id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '',
sourceModelId: metadata.source_model_id || metadata.model_code || '',
modelCode: metadata.model_code || '',
modelName: metadata.model_name || record.title || '未命名模型',
sourceModelUpdatedAt: metadata.source_model_updated_at || '',
modelState: metadata.model_state || '',
modelApplyState: metadata.model_apply_state || '',
modelRiskLevel: metadata.model_risk_level || '',
modelBusinessType: metadata.model_business_type || '',
modelBusinessTypeName: metadata.model_business_type_name || '',
description: metadata.description || '',
groupId: metadata.group_id || '',
groupName: metadata.group_name || '',
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
dataSubjectName: metadata.data_subject_name || '',
primaryKeyFields: Array.isArray(metadata.primary_key_fields) ? metadata.primary_key_fields : [],
lastSyncStatus: metadata.last_sync_status || '',
lastSyncedAt: metadata.last_synced_at || '',
createdById: metadata.created_by_user_id || '',
......@@ -338,6 +361,12 @@ function normalizeBatch(record = {}) {
modelName: metadata.model_name || '',
batchCode: metadata.batch_code || '',
batchName: metadata.batch_name || record.title || '未命名批次',
sourceBatchDate: metadata.source_batch_date || '',
sourceDataTableName: metadata.source_data_table_name || '',
sourceRemark: metadata.source_remark || '',
sourceUncheckedCount: Number(metadata.source_unchecked_count || 0),
sourceUnsubmittedCount: Number(metadata.source_unsubmitted_count || 0),
sourceReviewedCount: Number(metadata.source_reviewed_count || 0),
dataSubjectName: metadata.data_subject_name || '',
columnSchema: Array.isArray(metadata.column_schema) ? metadata.column_schema : [],
hiddenRecordIds: Array.isArray(metadata.hidden_record_ids) ? metadata.hidden_record_ids : [],
......@@ -363,9 +392,9 @@ function normalizeDataRecord(record = {}, columns = []) {
return {
id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '',
title: record.title || metadata.source_record_key || '未命名记录',
title: record.title || (metadata.source_row_number ? `第 ${metadata.source_row_number} 条` : '未命名记录'),
...values,
sourceRecordKey: metadata.source_record_key || '',
sourceRowNumber: Number(metadata.source_row_number || 0),
updatedAt: record.updated_at || metadata.source_synced_at || '',
};
}
......@@ -434,18 +463,48 @@ function createRecordName(prefix, value) {
return `${prefix}_${hashValue(value).slice(0, 32)}`;
}
function canonicalValue(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value, Object.keys(value).sort());
return String(value).trim();
}
function createSourceRecordKey(row, primaryKeyFields) {
const values = primaryKeyFields.map((field) => canonicalValue(row[field]));
if (values.some((value) => value === '')) {
throw new Error(`组合主键 ${primaryKeyFields.join(' + ')} 存在空值。`);
function normalizeSourceDate(value, label) {
if (value === null || value === undefined || value === '') return '';
const parsed = dayjs(value);
if (!parsed.isValid()) throw new Error(`${label}“${value}”不是有效日期。`);
return parsed.toISOString();
}
function normalizeSourceCount(value, label) {
if (value === null || value === undefined || value === '') return 0;
const count = Number(value);
if (!Number.isInteger(count) || count < 0) throw new Error(`${label}“${value}”不是有效的非负整数。`);
return count;
}
function validateSourceSelection(sourceModel = {}, sourceBatch = {}) {
const sourceModelId = String(sourceModel.source_model_id ?? '').trim();
const sourceModelName = String(sourceModel.source_model_name || sourceModel.model_code || '').trim();
const sourceBatchCode = String(sourceBatch.batch_code ?? '').trim();
const batchSourceModelId = String(sourceBatch.source_model_id ?? '').trim();
const batchSourceModelName = String(sourceBatch.source_model_name ?? '').trim();
const sourceDataTableName = String(sourceBatch.data_table_name ?? '').trim().toUpperCase();
if (!sourceModelId) throw new Error('来源模型缺少 ANALYZE_MXVIEW_INFO.ID。');
if (!sourceModelName) throw new Error('来源模型缺少 ANALYZE_MXVIEW_INFO.MODEL_NAME。');
if (!sourceBatchCode) throw new Error('来源批次缺少 MODEL_RUN_BATCH_ID。');
if (batchSourceModelId && batchSourceModelId !== sourceModelId) {
throw new Error('来源批次的 MODEL_ID 与所选模型 ID 不一致。');
}
if (!batchSourceModelName || batchSourceModelName !== sourceModelName) {
throw new Error('来源批次的 MODEL_NAME 与所选模型名称不一致。');
}
return hashValue(JSON.stringify(primaryKeyFields.map((field, index) => [field, values[index]])));
if (!/^[A-Z][A-Z0-9_$#]*(\.[A-Z][A-Z0-9_$#]*)?$/.test(sourceDataTableName)) {
throw new Error('来源批次的数据表名为空或不符合 Oracle 非引号标识符规范。');
}
return {
sourceModelId,
sourceModelName,
sourceModelKey: JSON.stringify([sourceModelName, sourceModelId]),
sourceBatchCode,
sourceDataTableName,
};
}
function normalizeColumn(column = {}) {
......@@ -456,12 +515,9 @@ function normalizeColumn(column = {}) {
};
}
function validateSourcePayload(model, payload) {
function validateSourcePayload(batch, payload) {
const allowedTypes = new Set(['text', 'number', 'date', 'boolean', 'object']);
const columns = (payload?.columns || []).map(normalizeColumn);
if (!Array.isArray(model.primary_key_fields) || model.primary_key_fields.length === 0) {
throw new Error('外部模型没有返回组合主键字段。');
}
if (columns.length === 0) throw new Error('批次数据没有返回字段定义。');
const aliases = new Set();
columns.forEach((column) => {
......@@ -470,16 +526,19 @@ function validateSourcePayload(model, payload) {
if (!allowedTypes.has(column.type)) throw new Error(`字段“${column.name}”使用了不支持的类型“${column.type}”。`);
aliases.add(column.name);
});
(model.primary_key_fields || []).forEach((field) => {
if (!aliases.has(field)) throw new Error(`组合主键字段“${field}”不在批次数据中。`);
});
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
const seenKeys = new Set();
if (batch.source_model_data_count !== null && batch.source_model_data_count !== undefined && batch.source_model_data_count !== '') {
const expectedCount = normalizeSourceCount(batch.source_model_data_count, '来源批次数据量');
if (expectedCount !== rows.length) {
throw new Error(`来源批次声明 ${expectedCount} 条数据,实际读取 ${rows.length} 条,已停止同步。`);
}
}
const normalizedRows = rows.map((row, index) => {
const sourceRecordKey = createSourceRecordKey(row, model.primary_key_fields || []);
if (seenKeys.has(sourceRecordKey)) throw new Error(`第 ${index + 1} 行与其他数据存在重复组合主键。`);
seenKeys.add(sourceRecordKey);
return { row, sourceRecordKey };
if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Error(`第 ${index + 1} 行不是有效对象。`);
const normalizedRow = Object.fromEntries(
columns.map((column) => [column.name, normalizeBusinessValue(row[column.name], column.type)]),
);
return { row: normalizedRow, sourceRowNumber: index + 1 };
});
return { columns, normalizedRows };
}
......@@ -504,11 +563,26 @@ function normalizeBusinessValue(value, type) {
}
async function ensureSubject(definition) {
let subject;
try {
return normalizeSubject(await loadSubject(definition.name));
subject = normalizeSubject(await loadSubject(definition.name));
} catch (_error) {
return normalizeSubject(await createSubject(toSubjectPayload(definition)));
}
const existingFields = new Map(subject.fields.map((field) => [field.alias, field]));
for (const field of definition.fields) {
const existing = existingFields.get(field.name);
if (!existing) {
await addSubjectField(definition.name, toFieldPayload({ ...field, required: false }));
continue;
}
if (String(existing.type).toLowerCase() !== String(field.type).toLowerCase()) {
throw new Error(`对象“${definition.name}”的字段“${field.label}”类型由 ${existing.type} 变为 ${field.type},已停止初始化。`);
}
}
return normalizeSubject(await loadSubject(definition.name));
}
export async function ensureModelMonitorSubjects() {
......@@ -523,24 +597,7 @@ async function ensureDataSubject(subjectName, modelName, columns) {
title: `${modelName}数据`,
fields: [...SYSTEM_DATA_FIELDS, ...columns],
};
let subject;
try {
subject = normalizeSubject(await loadSubject(subjectName));
} catch (_error) {
return ensureSubject(definition);
}
const existingFields = new Map(subject.fields.map((field) => [field.alias, field]));
for (const field of definition.fields) {
const existing = existingFields.get(field.name);
if (!existing) {
await addSubjectField(subjectName, toFieldPayload(field));
continue;
}
if (String(existing.type).toLowerCase() !== String(field.type).toLowerCase()) {
throw new Error(`字段“${field.label}”类型由 ${existing.type} 变为 ${field.type},已停止同步。`);
}
}
return normalizeSubject(await loadSubject(subjectName));
return ensureSubject(definition);
}
export async function loadSubjectDefinition(subjectName) {
......@@ -588,14 +645,20 @@ export async function saveModelDetails(model, values) {
subject: MODEL_SUBJECT,
content_type: 'text',
metadata: {
source_model_id: model.sourceModelId,
model_code: model.modelCode,
model_name: model.modelName,
source_model_updated_at: model.sourceModelUpdatedAt || '',
model_state: model.modelState || '',
model_apply_state: model.modelApplyState || '',
model_risk_level: model.modelRiskLevel || '',
model_business_type: model.modelBusinessType || '',
model_business_type_name: model.modelBusinessTypeName || '',
description: String(values.description || '').trim(),
group_id: values.groupId || '',
group_name: values.groupName || '',
tags: Array.isArray(values.tags) ? values.tags : [],
data_subject_name: model.dataSubjectName,
primary_key_fields: model.primaryKeyFields,
last_sync_status: model.lastSyncStatus || '',
last_synced_at: model.lastSyncedAt || '',
created_by_user_id: model.createdById || '',
......@@ -619,8 +682,9 @@ export async function loadModelBatch(batchId) {
return normalizeBatch(await loadRecord(batchId));
}
async function saveBatchPayload(batch, metadata) {
async function saveBatchPayload(batch, metadata, stableRecordName = '') {
const payload = {
...(batch?.recordName || !stableRecordName ? {} : { name: stableRecordName }),
title: metadata.batch_name,
subject: MODEL_BATCH_SUBJECT,
content_type: 'text',
......@@ -637,6 +701,12 @@ export async function updateBatchHiddenRecords(batch, hiddenRecordIds) {
model_name: batch.modelName,
batch_code: batch.batchCode,
batch_name: batch.batchName,
source_batch_date: batch.sourceBatchDate,
source_data_table_name: batch.sourceDataTableName,
source_remark: batch.sourceRemark,
source_unchecked_count: batch.sourceUncheckedCount,
source_unsubmitted_count: batch.sourceUnsubmittedCount,
source_reviewed_count: batch.sourceReviewedCount,
data_subject_name: batch.dataSubjectName,
column_schema: batch.columnSchema,
hidden_record_ids: hiddenRecordIds,
......@@ -667,17 +737,25 @@ export async function loadAllBatchRecords(batch, includeHidden = false) {
return records.map((record) => normalizeDataRecord(record, batch.columnSchema));
}
async function findModelByCode(modelCode) {
const response = await queryRecords({ subject: MODEL_SUBJECT, page: 0, page_size: 2, filter: { 'metadata.model_code': modelCode } });
async function findModelBySourceKey(sourceModelId, modelCode) {
const response = await queryRecords({
subject: MODEL_SUBJECT,
page: 0,
page_size: 2,
filter: {
'metadata.source_model_id': sourceModelId,
'metadata.model_code': modelCode,
},
});
return extractArray(response).map(normalizeModel)[0] || null;
}
async function findBatch(modelId, batchCode) {
async function findBatch(batchCode) {
const response = await queryRecords({
subject: MODEL_BATCH_SUBJECT,
page: 0,
page_size: 2,
filter: { 'metadata.model_id': modelId, 'metadata.batch_code': batchCode },
filter: { 'metadata.batch_code': batchCode },
});
return extractArray(response).map(normalizeBatch)[0] || null;
}
......@@ -688,14 +766,20 @@ async function saveModelSyncState(model, status, syncedAt) {
subject: MODEL_SUBJECT,
content_type: 'text',
metadata: {
source_model_id: model.sourceModelId,
model_code: model.modelCode,
model_name: model.modelName,
source_model_updated_at: model.sourceModelUpdatedAt || '',
model_state: model.modelState || '',
model_apply_state: model.modelApplyState || '',
model_risk_level: model.modelRiskLevel || '',
model_business_type: model.modelBusinessType || '',
model_business_type_name: model.modelBusinessTypeName || '',
description: model.description || '',
group_id: model.groupId || '',
group_name: model.groupName || '',
tags: model.tags || [],
data_subject_name: model.dataSubjectName,
primary_key_fields: model.primaryKeyFields,
last_sync_status: status,
last_synced_at: syncedAt || model.lastSyncedAt || '',
created_by_user_id: model.createdById || '',
......@@ -712,6 +796,12 @@ function batchMetadata(model, sourceBatch, actor, overrides = {}) {
model_name: model.modelName,
batch_code: sourceBatch.batch_code,
batch_name: sourceBatch.batch_name,
source_batch_date: normalizeSourceDate(sourceBatch.source_batch_date, '来源跑批时间'),
source_data_table_name: String(sourceBatch.data_table_name || '').toUpperCase(),
source_remark: sourceBatch.remark || '',
source_unchecked_count: normalizeSourceCount(sourceBatch.source_unchecked_count, '来源未检查数量'),
source_unsubmitted_count: normalizeSourceCount(sourceBatch.source_unsubmitted_count, '来源未提交数量'),
source_reviewed_count: normalizeSourceCount(sourceBatch.source_reviewed_count, '来源已复核数量'),
data_subject_name: model.dataSubjectName,
column_schema: overrides.columnSchema || [],
hidden_record_ids: overrides.hiddenRecordIds || [],
......@@ -731,32 +821,76 @@ function batchMetadata(model, sourceBatch, actor, overrides = {}) {
export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onProgress = () => {}) {
const actor = currentUserValues(user);
const sourcePayload = await loadSourceBatchData(sourceModel.model_name, sourceBatch.batch_code);
const { columns, normalizedRows } = validateSourcePayload(sourceModel, sourcePayload);
const {
sourceModelId,
sourceModelName,
sourceModelKey,
sourceBatchCode,
sourceDataTableName,
} = validateSourceSelection(sourceModel, sourceBatch);
const normalizedSourceModel = {
...sourceModel,
source_model_id: sourceModelId,
source_model_name: sourceModelName,
model_code: sourceModelName,
model_name: String(sourceModel.model_name || sourceModelName),
};
const normalizedSourceBatch = {
...sourceBatch,
source_model_id: sourceModelId,
batch_code: sourceBatchCode,
batch_name: String(sourceBatch.batch_name || sourceBatch.source_batch_date || sourceBatchCode),
data_table_name: sourceDataTableName,
};
let model = await findModelBySourceKey(sourceModelId, sourceModelName);
let batch = await findBatch(sourceBatchCode);
if (batch) {
const alreadySyncedError = new Error('该批次已经执行过同步,不能再次同步。');
alreadySyncedError.code = 'BATCH_ALREADY_SYNCED';
throw alreadySyncedError;
}
const sourcePayload = await loadSourceBatchData(normalizedSourceModel, normalizedSourceBatch);
const { columns, normalizedRows } = validateSourcePayload(normalizedSourceBatch, sourcePayload);
onProgress({ stage: 'validating', completed: 0, total: normalizedRows.length });
let model = await findModelByCode(sourceModel.model_code);
let batch = null;
const subjectName = model?.dataSubjectName || dataSubjectName(sourceModel.model_code);
const subjectName = model?.dataSubjectName || dataSubjectName(sourceModelKey);
try {
if (model) {
if (JSON.stringify(model.primaryKeyFields) !== JSON.stringify(sourceModel.primary_key_fields)) {
throw new Error('外部模型返回的组合主键与本地模型配置不一致。');
}
model = {
...model,
sourceModelId,
modelCode: normalizedSourceModel.model_code,
modelName: normalizedSourceModel.model_name,
sourceModelUpdatedAt: normalizeSourceDate(normalizedSourceModel.source_model_updated_at, '来源模型数据更新时间'),
modelState: normalizedSourceModel.model_state || '',
modelApplyState: normalizedSourceModel.model_apply_state || '',
modelRiskLevel: normalizedSourceModel.model_risk_level || '',
modelBusinessType: normalizedSourceModel.model_business_type || '',
modelBusinessTypeName: normalizedSourceModel.model_business_type_name || '',
};
} else {
const response = await createRecord({
title: sourceModel.model_name,
name: createRecordName('model', sourceModelKey),
title: normalizedSourceModel.model_name,
subject: MODEL_SUBJECT,
content_type: 'text',
metadata: {
model_code: sourceModel.model_code,
model_name: sourceModel.model_name,
source_model_id: sourceModelId,
model_code: normalizedSourceModel.model_code,
model_name: normalizedSourceModel.model_name,
source_model_updated_at: normalizeSourceDate(normalizedSourceModel.source_model_updated_at, '来源模型数据更新时间'),
model_state: normalizedSourceModel.model_state || '',
model_apply_state: normalizedSourceModel.model_apply_state || '',
model_risk_level: normalizedSourceModel.model_risk_level || '',
model_business_type: normalizedSourceModel.model_business_type || '',
model_business_type_name: normalizedSourceModel.model_business_type_name || '',
description: '',
group_id: '',
group_name: '',
tags: [],
data_subject_name: subjectName,
primary_key_fields: sourceModel.primary_key_fields,
last_sync_status: 'syncing',
last_synced_at: '',
created_by_user_id: actor.id,
......@@ -766,76 +900,45 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr
model = normalizeModel(response?.data || response?.result || response);
}
batch = await findBatch(model.id, sourceBatch.batch_code);
if (batch?.syncStatus === 'success') {
const alreadySyncedError = new Error('该批次已经同步成功,不能再次同步。');
alreadySyncedError.code = 'BATCH_ALREADY_SYNCED';
throw alreadySyncedError;
}
if (batch?.syncStatus === 'syncing' && dayjs().diff(dayjs(batch.syncStartedAt), 'minute') < 30) {
throw new Error('该模型批次正在同步,请稍后再试。');
}
batch = await saveBatchPayload(batch, batchMetadata(model, sourceBatch, actor, {
batch = await saveBatchPayload(null, batchMetadata(model, normalizedSourceBatch, actor, {
columnSchema: columns,
hiddenRecordIds: batch?.hiddenRecordIds || [],
hiddenRecordIds: [],
syncStatus: 'syncing',
sourceRowCount: batch?.sourceRowCount || 0,
totalRecordCount: batch?.totalRecordCount || 0,
insertedCount: batch?.insertedCount || 0,
updatedCount: batch?.updatedCount || 0,
failedCount: batch?.failedCount || 0,
sourceRowCount: normalizedRows.length,
syncStartedAt: dayjs().toISOString(),
}));
}), createRecordName('model_batch', sourceBatchCode));
await saveModelSyncState(model, 'syncing', '');
await ensureDataSubject(subjectName, model.modelName, columns);
const existingRecords = await queryAllRecordPages({
subject: subjectName,
filter: { 'metadata.source_batch_id': batch.id },
});
const existingByKey = new Map(
existingRecords.map((record) => [record.metadata?.source_record_key, record]),
);
const columnMap = new Map(columns.map((column) => [column.name, column]));
let insertedCount = 0;
let updatedCount = 0;
let failedCount = 0;
const errors = [];
const syncedAt = dayjs().toISOString();
for (let start = 0; start < normalizedRows.length; start += 20) {
const chunk = normalizedRows.slice(start, start + 20);
const results = await Promise.allSettled(chunk.map(async ({ row, sourceRecordKey }) => {
const businessMetadata = Object.fromEntries(
columns.map((column) => [column.name, normalizeBusinessValue(row[column.name], columnMap.get(column.name)?.type)]),
);
const primaryTitle = sourceModel.primary_key_fields.map((field) => canonicalValue(row[field])).join(' / ');
const results = await Promise.allSettled(chunk.map(async ({ row, sourceRowNumber }) => {
const payload = {
name: createRecordName('model_data', `${model.id}:${batch.id}:${sourceRecordKey}`),
title: primaryTitle || sourceRecordKey.slice(0, 12),
name: createRecordName('model_data', `${sourceBatchCode}:${sourceRowNumber}`),
title: `第 ${sourceRowNumber} 条`,
subject: subjectName,
content_type: 'text',
metadata: {
...businessMetadata,
...row,
source_model_id: model.id,
source_external_model_id: sourceModelId,
source_batch_id: batch.id,
source_batch_code: batch.batchCode,
source_record_key: sourceRecordKey,
source_data_table_name: sourceDataTableName,
source_row_number: sourceRowNumber,
source_synced_at: syncedAt,
},
};
const existing = existingByKey.get(sourceRecordKey);
if (existing) {
await updateRecord(existing.name || existing._id, payload);
return 'updated';
}
await createRecord(payload);
return 'inserted';
}));
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value === 'inserted') insertedCount += 1;
else if (result.status === 'fulfilled') updatedCount += 1;
if (result.status === 'fulfilled') insertedCount += 1;
else {
failedCount += 1;
errors.push(result.reason?.message || '记录写入失败');
......@@ -847,25 +950,25 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr
const countResponse = await countRecords({ subject: subjectName, filter: { 'metadata.source_batch_id': batch.id } });
const finalStatus = failedCount > 0 ? 'partial' : 'success';
const completedAt = dayjs().toISOString();
batch = await saveBatchPayload(batch, batchMetadata(model, sourceBatch, actor, {
batch = await saveBatchPayload(batch, batchMetadata(model, normalizedSourceBatch, actor, {
columnSchema: columns,
hiddenRecordIds: batch.hiddenRecordIds,
syncStatus: finalStatus,
sourceRowCount: normalizedRows.length,
totalRecordCount: extractCount(countResponse) || insertedCount + updatedCount,
totalRecordCount: extractCount(countResponse) || insertedCount,
insertedCount,
updatedCount,
updatedCount: 0,
failedCount,
syncStartedAt: batch.syncStartedAt,
syncedAt: completedAt,
errorMessage: errors.slice(0, 3).join(';'),
}));
model = await saveModelSyncState(model, finalStatus, completedAt);
return { model, batch, insertedCount, updatedCount, failedCount };
return { model, batch, insertedCount, updatedCount: 0, failedCount };
} catch (error) {
const isAlreadySynced = error?.code === 'BATCH_ALREADY_SYNCED';
if (batch && !isAlreadySynced) {
await saveBatchPayload(batch, batchMetadata(model, sourceBatch, actor, {
if (batch) {
try {
await saveBatchPayload(batch, batchMetadata(model, normalizedSourceBatch, actor, {
columnSchema: batch.columnSchema || columns,
hiddenRecordIds: batch.hiddenRecordIds || [],
syncStatus: 'failed',
......@@ -877,10 +980,17 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr
syncStartedAt: batch.syncStartedAt,
syncedAt: dayjs().toISOString(),
errorMessage: error?.message || '同步失败',
}));
}));
} catch (_saveBatchError) {
// Preserve the original synchronization error for the caller.
}
}
if (model?.recordName && !isAlreadySynced) {
await saveModelSyncState(model, 'failed', dayjs().toISOString());
if (model?.recordName) {
try {
await saveModelSyncState(model, 'failed', dayjs().toISOString());
} catch (_saveModelError) {
// Preserve the original synchronization error for the caller.
}
}
throw error;
}
......@@ -898,6 +1008,16 @@ export function formatFieldValue(field, value) {
if (value === null || value === undefined || value === '') return '-';
if (field?.type === 'date') return formatDateTime(value);
if (field?.type === 'boolean') return value ? '是' : '否';
const options = field?.settings?.options || field?.options || [];
if (field?.type === 'select' || options.length > 0) {
const values = Array.isArray(value) ? value : [value];
return values
.map(
(item) =>
options.find((option) => String(option.value) === String(item))?.label || String(item),
)
.join('、');
}
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
......@@ -931,16 +1051,16 @@ export function getTruthStatusLabel(status) {
}
export function getForwardRowStatusLabel(status) {
return ({ pending_send: '待发送', editing: '进行中', pending: '待审批', first_approved: '已审批', approved: '已最终审批', rejected: '已驳回' })[status] || '待发送';
return ({ pending_send: '待发送', editing: '进行中', returned: '驳回待修改', pending: '待审批', first_approved: '已审批', approved: '已最终审批', rejected: '已驳回' })[status] || '待发送';
}
export function getForwardRowStatusTone(status) {
return ({ editing: 'sky', pending: 'amber', first_approved: 'sky', approved: 'emerald', rejected: 'rose' })[status] || 'default';
return ({ editing: 'sky', returned: 'rose', pending: 'amber', first_approved: 'sky', approved: 'emerald', rejected: 'rose' })[status] || 'default';
}
export function resolveForwardRowStatus(record) {
if (!record) return 'pending_send';
if (record.approvalStatus === 'rejected' || record.finalApprovalStatus === 'rejected') return 'rejected';
if (record.approvalStatus === 'rejected' || record.finalApprovalStatus === 'rejected') return 'returned';
if (record.editorStatus !== 'completed') return 'editing';
if (record.approvalStatus === 'pending') return 'pending';
if (record.finalApprovalStatus === 'approved') return 'approved';
......@@ -952,7 +1072,7 @@ export function buildForwardStatusIndex(records = []) {
const latest = new Map();
const flowHidden = new Set();
[...records].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))).forEach((record) => {
const closed = record.approvalStatus === 'rejected' || record.finalApprovalStatus === 'approved' || record.finalApprovalStatus === 'rejected';
const closed = record.finalApprovalStatus === 'approved';
if (!closed) record.excludedRecordIds.forEach((id) => flowHidden.add(String(id)));
record.selectedRecordIds.forEach((id) => {
const key = String(id);
......@@ -1034,6 +1154,19 @@ export async function saveForwardRecord(values, user) {
function createForwardItemPayload(values, user) {
const actor = currentUserValues(user);
const hasFill = Boolean(values.truthStatus || values.rectificationPlan || values.expectedRectificationCompletedAt);
const fillMetadata = {
...(values.truthStatus ? { truth_status: values.truthStatus } : {}),
...(String(values.rectificationPlan || '').trim()
? { rectification_plan: String(values.rectificationPlan).trim() }
: {}),
...(values.expectedRectificationCompletedAt
? {
expected_rectification_completed_at: dayjs(
values.expectedRectificationCompletedAt,
).toISOString(),
}
: {}),
};
return {
title: values.sourceRecordTitle || '转发回填明细',
subject: MODEL_FORWARD_ITEM_SUBJECT,
......@@ -1048,9 +1181,7 @@ function createForwardItemPayload(values, user) {
data_subject_name: values.dataSubjectName || '',
source_record_id: values.sourceRecordId,
source_record_title: values.sourceRecordTitle || '',
truth_status: values.truthStatus || '',
rectification_plan: String(values.rectificationPlan || '').trim(),
expected_rectification_completed_at: values.expectedRectificationCompletedAt ? dayjs(values.expectedRectificationCompletedAt).toISOString() : '',
...fillMetadata,
filled_by_id: values.filledById || (hasFill ? actor.id : ''),
filled_by_name: values.filledByName || (hasFill ? actor.name : ''),
filled_at: values.filledAt || (hasFill ? dayjs().toISOString() : ''),
......
# 模型监控接口文档
**最后更新**: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