Files
group_fqcd_jr/app/static/portal/employee-operations/offsite/offsite.js
T
lzf_0626 5b97475950 场外前端:单据核对区显示不出字段 —— 邮件详情的单据只是摘要,须与识别接口按 task_id 合并
## 现象

"单据核对与运营动作"里的单据信息全是 `--`:

```
20260914-001-A01
subscription · -- · --
申请日期 --        申购金额 / 赎回份额 -- / --
机构     --        基金代码            --
```

而 OCR 识别字段里这些值都**有**(基金代码 15911 / 申请日期 2026-09-11 /
申购金额 5000.00 / 机构 星澜财富服务中心)。所以不是"没保存",是**渲染时取错了数据源**。

## 根因:同一个单据,两个接口给的不是同一份字段

| 接口 | `attachments[].documents[]` 的字段 |
|---|---|
| `GET /mails/{id}`(`_mail_detail`) | **只有摘要 4 个键**:`task_id` / `document_type` / `status` / `operator_decision` |
| `GET /mails/{id}/recognition-fields`(`_recognition_payload`) | **全部标准化字段**:`fund_code` / `fund_name` / `application_no` / `application_date` / `agency` / `subscription_amount_yuan` … |

`renderDocuments()` 渲染单据 kv 用的是 `state.documents`,而 `loadMail()` 只从
**邮件详情**那一份构建它 ⇒ 标准化字段根本不在对象里,只能显示 `--`。

前端其实**已经有** `syncDocumentsFromRecognition()` 想做这件事,但 `loadMail()` 里
是「先 `syncDocumentsFromRecognition(...)`、紧接着又用邮件详情重建 `state.documents`」
—— 合并结果被后一行覆盖掉了(这也是保存识别字段后那次同步没生效的原因)。

## 修法

1. `loadMail()`:构建完 `state.documents`(邮件侧摘要)之后**再调用一次**
   `syncDocumentsFromRecognition(state.recognition)`,把识别侧的标准字段合并进来;
2. `syncDocumentsFromRecognition()` 由"整段替换"改为**逐条按 `task_id` 合并**:
   - 两侧都有 → 识别侧字段优先,摘要里独有的键(`status` / `operator_decision`)保留;
   - 只有摘要侧 → 原样保留(不因为识别接口没提到就丢单据);
   - 只有识别侧 → 也补进来(例如刚生成、摘要还没刷新);
   - 识别接口无 attachments(请求失败等)→ 早退,**不清空**已有单据。

## 验证

`node --check offsite.js` 通过;并用 Node **从源文件抽出该函数**(不另抄一份逻辑)灌入
线上两个接口的真实载荷形态,6+4+3+1 项断言全部通过:

- 摘要 + 识别侧全字段 → `fund_code=15911` / `application_date=2026-09-11` /
  `agency=星澜财富服务中心` / `subscription_amount_yuan=5000.0000`,且
  `status=planned`、`operator_decision=未处理` 未被覆盖,`attachment_id` 已补上;
- 识别接口返回空 → 邮件侧摘要仍在;
- 邮件侧独有单据保留、识别侧独有单据出现。

前端相关测试:`tests/unit/api/test_portal_frontend.py` → 45 passed, 1 failed
(仍是组员在改的投顾页,与本次无关);`pytest tests/unit/api -k "offsite or operations"`
→ 1 passed。

## 说明:为什么没改后端

也可以让 `_mail_detail` 直接带上标准化字段,但那会改动已被文档化的接口载荷形状;
而前端本来就有合并函数、意图明确(`save-ocr` 路径早就在调用它),
因此按"恢复原有意图 + 按 task_id 正确合并"来修,零接口契约风险。
2026-09-14 22:23:49 +08:00

673 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiClient } from '/static/portal/common/api-client.js?v=20260921';
import { getAuthContext, requireOperator } from '/static/portal/common/auth.js';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { showToast } from '/static/portal/common/notifications.js';
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
const PREVIEW_TYPES = new Set(['application/pdf', 'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif']);
const NOTIFICATION_TYPES = [['mail_return', '邮件回执'], ['normal_return', '正常回执'], ['exception_return', '异常回执'], ['risk', '风险通知'], ['settlement', '清算通知']];
const NOTIFICATION_LABELS = Object.fromEntries(NOTIFICATION_TYPES);
const MAIL_STATUS_FILTERS = [
['exception', '有异常'],
['processed', '已处理'],
['processing', '处理中'],
['inbox', '已入库'],
['replied', '已回执'],
['all', '全部邮件'],
];
if (requireOperator()) {
mountShell({ active: 'operator-offsite', mode: 'operator' });
const context = getAuthContext();
const operatorId = String(context?.userId || context?.username || '');
const pageParams = new URLSearchParams(location.search);
const state = {
page: Number(pageParams.get('page')) > 0 ? Number(pageParams.get('page')) : 1, pageSize: 10, total: 0, mails: [], mailbox: null, activeMailStatus: 'all',
mailDetails: {},
selectedMailId: '', mail: null, recognition: null, documents: [], nl2sql: {}, rules: {},
ocrDrafts: {}, nlDrafts: {}, notice: null, noticeTaskId: '', noticeDraft: '', stats: null,
statsForm: { fundCode: '', applicationDate: '' },
recalculatingTasks: new Set(),
requestedMailId: pageParams.get('mail_id') || '',
};
const root = document.querySelector('main');
const targets = {
metrics: document.querySelector('[data-offsite-metrics]'),
statusTabs: document.querySelector('[data-mail-status-tabs]'),
list: document.querySelector('[data-mail-list]'),
pagination: document.querySelector('[data-mail-pagination]'),
detail: document.querySelector('[data-mail-detail]'),
documents: document.querySelector('[data-document-list]'),
noticePanel: document.querySelector('[data-notification-panel]'),
notice: document.querySelector('[data-notification]'),
statistics: document.querySelector('[data-statistics]'),
};
document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员';
document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
function value(item, fallback = '--') {
if (item === null || item === undefined || item === '') return fallback;
if (Array.isArray(item)) return item.length ? item.join('、') : fallback;
if (typeof item === 'object') return JSON.stringify(item);
return String(item);
}
function tag(text, tone = '') {
return `<span class="tag ${tone ? `status-tag--${tone}` : 'tag--neutral'}">${escapeHtml(text || '--')}</span>`;
}
function actionButton(label, action, tone = '', disabled = false) {
return `<button class="button table-action${tone ? ` button--${tone}` : ''}" type="button" data-action="${escapeHtml(action)}"${disabled ? ' disabled' : ''}>${escapeHtml(label)}</button>`;
}
function fieldStatusLabel(status) {
return {
success: '成功',
query_failed: '失败',
not_queried: '未查询',
pending: '待查询',
corrected: '人工修正',
}[String(status || '').toLowerCase()] || '未查询';
}
function documentStatusLabel(status) {
return {
operator_confirmed: '已处理',
recognition_exception: '识别异常',
recognition_review: '待复核',
recognition_retrying: '识别重试中',
query_failed: '查询失败',
planned: '已入库',
recognized: '已入库',
received: '已入库',
normal_return_sent: '已回执',
completed: '已完成',
deleted: '已删除',
processing: '处理中',
}[String(status || '').toLowerCase()] || status || '已读取';
}
function documentNotificationType(document) {
if (document?.operator_decision === '确认正常') return 'normal_return';
if (document?.operator_decision === '确认异常') return 'exception_return';
return '';
}
function mailDocuments(mail) {
return (mail?.attachments || []).flatMap((attachment) => attachment.documents || []);
}
function mailDisplayStatus(mail) {
const internalStatus = String(mail?.status || '');
const documents = mailDocuments(state.mailDetails[mail?.mail_id]);
const decisions = documents.map((item) => item.operator_decision).filter(Boolean);
if (internalStatus === 'processing') {
if (decisions.includes('确认异常')) return { key: 'exception', label: '有异常', tone: 'high' };
if (decisions.length && decisions.every((decision) => decision === '确认正常')) {
return { key: 'processed', label: '已处理', tone: 'low' };
}
return { key: 'processing', label: '处理中', tone: 'medium' };
}
if (internalStatus === 'recognized' || internalStatus === 'received') {
return { key: 'inbox', label: '已入库', tone: 'active' };
}
if (internalStatus === 'normal_return_sent' || internalStatus === 'completed') {
return { key: 'replied', label: '已回执', tone: 'low' };
}
if (internalStatus === 'deleted') return { key: 'deleted', label: '已删除', tone: 'neutral' };
return { key: 'processing', label: '处理中', tone: 'medium' };
}
function documentDisplayStatus(document) {
if (document.operator_decision === '确认异常') return { label: '有异常', tone: 'high' };
if (document.operator_decision === '确认正常') return { label: '已处理', tone: 'low' };
if (document.status === 'recognized' || document.status === 'planned') {
return { label: '已入库', tone: 'active' };
}
return { label: '处理中', tone: 'medium' };
}
function ruleComparison(row) {
const calculation = row?.calculation || {};
if (calculation.实际值 !== undefined && calculation.规则值 !== undefined) {
return {
actual: calculation.实际值,
rule: calculation.规则值,
expression: calculation.比较,
};
}
if (row?.rule_code === 'subscription_minimum_amount') {
return {
actual: row.document_value?.申购金额元,
rule: '> 1 元',
expression: row.document_value?.申购金额元 === undefined
? ''
: `${row.document_value.申购金额元} > 1 元`,
};
}
if (calculation.申购后持有比例 !== undefined) {
const actual = `${Number(calculation.申购后持有比例) * 100}%`;
return { actual, rule: '≤ 20%', expression: `${actual} ≤ 20%` };
}
if (calculation.本次申购份额 !== undefined && calculation.份额上限 !== undefined) {
return {
actual: calculation.本次申购份额,
rule: calculation.份额上限,
expression: `${calculation.本次申购份额} ≤ ${calculation.份额上限}`,
};
}
if (calculation.赎回比例 !== undefined) {
const actual = `${Number(calculation.赎回比例) * 100}%`;
return { actual, rule: '≤ 20%', expression: `${actual} ≤ 20%` };
}
if (row?.rule_code === 'redemption_available_quantity') {
return {
actual: row.document_value?.赎回份额,
rule: row.database_value?.当前最新可用份额,
expression: row.document_value?.赎回份额 === undefined
|| row.database_value?.当前最新可用份额 === undefined
? ''
: `${row.document_value.赎回份额} ≤ ${row.database_value.当前最新可用份额}`,
};
}
return { actual: '', rule: '', expression: '' };
}
function numericValue(value) {
if (value === null || value === undefined || value === '') return null;
const normalized = Number(String(value).replace(/,/g, '').replace(/%$/, ''));
return Number.isFinite(normalized) ? normalized : null;
}
function formatFixed(value, digits = 2) {
const number = numericValue(value);
if (number === null) return value === undefined || value === null || value === '' ? '--' : String(value);
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function formatAmount(value) {
return formatFixed(value, 2);
}
function formatNav(value) {
return formatFixed(value, 4);
}
function isNumericDisplayField(name) {
return /金额|份额|总份额|持有份额|可用份额|subscription_amount_yuan|redemption_amount_yuan|redemption_shares/.test(String(name || ''));
}
function formatFieldValue(name, value) {
if (/净值|nav/i.test(String(name || ''))) return formatNav(value);
if (isNumericDisplayField(name)) return formatAmount(value);
return value === undefined || value === null ? '' : String(value);
}
function formatExpression(value) {
return String(value || '').replace(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?%?/gi, (match) => {
const isPercent = match.endsWith('%');
const number = numericValue(match);
if (number === null) return match;
return `${number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${isPercent ? '%' : ''}`;
});
}
function renderDocumentValue(row) {
const documentValue = row?.document_value || {};
const subscriptionAmount = documentValue.申购金额 ?? documentValue.申购金额元 ?? documentValue.subscription_amount_yuan;
if (subscriptionAmount !== undefined && subscriptionAmount !== null && subscriptionAmount !== '') {
return `申购金额:${formatAmount(subscriptionAmount)}`;
}
const redemptionAmount = documentValue.赎回金额 ?? documentValue.赎回金额元 ?? documentValue.redemption_amount_yuan ?? documentValue.赎回份额;
if (redemptionAmount !== undefined && redemptionAmount !== null && redemptionAmount !== '') {
return `赎回金额:${formatAmount(redemptionAmount)}`;
}
return '--';
}
function renderRuleComparison(row) {
const comparison = ruleComparison(row);
if (!comparison.actual && !comparison.rule) {
return '<span class="operator-inline-note">暂无可对比数据</span>';
}
const conclusion = row.result === '正常' ? '满足规则' : row.result === '异常' ? '不满足规则' : '无法判断';
const tone = row.result === '正常' ? 'operator-comparison--normal' : row.result === '异常' ? 'operator-comparison--abnormal' : 'operator-comparison--unknown';
return `<div class="operator-comparison ${tone}">
<div class="operator-comparison__expression">${escapeHtml(comparison.expression ? `${formatExpression(comparison.expression)} · ${conclusion}` : conclusion)}</div>
</div>`;
}
function renderStatusTabs() {
targets.statusTabs.innerHTML = MAIL_STATUS_FILTERS.map(([key, label]) => `
<button class="operator-tab" type="button" role="tab" aria-selected="${state.activeMailStatus === key}" data-mail-status="${key}">${label}</button>
`).join('');
}
function errorText(error) {
return `<div class="operator-warning">${escapeHtml(error?.message || '请求未完成')}</div>`;
}
function renderMetrics() {
const selectedDocs = state.documents.length;
const pending = state.documents.filter((item) => item.operator_decision === '未处理' || !item.operator_decision).length;
const blocked = Boolean(state.mailbox?.blocked);
targets.metrics.innerHTML = [
['业务邮件', state.total, `当前第 ${state.page} 页`],
['当前单据', selectedDocs, state.selectedMailId ? '来自当前邮件' : '请选择邮件'],
['待人工确认', pending, '以服务端单据状态为准'],
['收件状态', blocked ? '已阻塞' : (state.mailbox?.monitoring ? '运行中' : '未启用'), blocked ? '需要恢复游标' : '服务端状态'],
].map(([label, current, meta]) => `<article class="metric-card"><p class="metric-card__label">${label}</p><p class="metric-card__value">${escapeHtml(String(current))}</p><p class="metric-card__meta">${escapeHtml(meta)}</p></article>`).join('');
}
function renderMailList() {
const visibleMails = state.mails.filter((mail) => (
state.activeMailStatus === 'all' || mailDisplayStatus(mail).key === state.activeMailStatus
));
if (!visibleMails.length) {
const activeLabel = MAIL_STATUS_FILTERS.find(([key]) => key === state.activeMailStatus)?.[1] || '业务';
renderEmpty(targets.list, `暂无${activeLabel}`, '当前状态下没有可展示的场外基金邮件。');
targets.pagination.innerHTML = '';
return;
}
targets.list.innerHTML = visibleMails.map((mail) => {
const active = mail.mail_id === state.selectedMailId;
const displayStatus = mailDisplayStatus(mail);
return `<div class="operator-list__item${active ? ' operator-list__item--active' : ''}" data-mail="${escapeHtml(mail.mail_id)}">
<div><strong>${escapeHtml(mail.subject || mail.mail_id || '无主题邮件')}</strong><small>${escapeHtml(mail.sender || '未知发件人')}</small><small>发送日期:${escapeHtml(formatDateTime(mail.sent_at || mail.received_at || mail.received_date))}</small></div>
<div class="operator-list__actions">${tag(displayStatus.label, displayStatus.tone)}${actionButton('删除', `delete-mail:${encodeURIComponent(mail.mail_id)}`, 'danger')}</div>
</div>`;
}).join('');
const pages = Math.max(1, Math.ceil(state.total / state.pageSize));
targets.pagination.innerHTML = `<span>第 ${state.page} / ${pages} 页,共 ${state.total} 封</span>${actionButton('上一页', 'page:prev', '')}${actionButton('下一页', 'page:next', '')}`;
targets.pagination.querySelector('[data-action="page:prev"]')?.toggleAttribute('disabled', state.page <= 1);
targets.pagination.querySelector('[data-action="page:next"]')?.toggleAttribute('disabled', state.page >= pages);
}
function fieldNames(base, effective, extra = []) {
const names = [];
[...Object.keys(base || {}), ...Object.keys(effective || {}), ...extra].forEach((name) => {
if (name && !names.includes(name)) names.push(name);
});
return names;
}
function renderFieldGrid(fields, draft, scope, key, statuses = {}) {
const names = fieldNames(fields, draft);
if (!names.length) return '<div class="operator-inline-note">暂无可展示字段。</div>';
return `<div class="operator-field-grid">${names.map((name) => {
const statusLabel = fieldStatusLabel(statuses[name]);
const shouldShowStatus = statuses[name] !== undefined && statuses[name] !== null && statuses[name] !== '' && statusLabel !== '未查询';
const fieldValue = draft[name] ?? fields?.[name] ?? '';
return `<label class="form-field"><span class="form-field__label operator-field-label"><span>${escapeHtml(name)}</span>${shouldShowStatus ? `<span class="operator-field-confidence">状态:${escapeHtml(statusLabel)}</span>` : ''}</span><input class="form-field__input" data-${scope}-field="${escapeHtml(key)}" data-field-name="${escapeHtml(name)}" value="${escapeHtml(formatFieldValue(name, fieldValue))}"></label>`;
}).join('')}</div>`;
}
function renderDetail() {
if (!state.mail) {
renderEmpty(targets.detail, '请选择一封邮件', '邮件详情会展示正文、附件原件、OCR 识别字段和关联单据。');
return;
}
const attachments = state.mail.attachments || [];
targets.detail.innerHTML = `<dl class="operator-kv"><div><dt>邮件编号</dt><dd>${escapeHtml(value(state.mail.mail_id))}</dd></div><div><dt>发件人</dt><dd>${escapeHtml(value(state.mail.sender))}</dd></div><div><dt>接收时间</dt><dd>${escapeHtml(formatDateTime(state.mail.received_at || state.mail.received_date))}</dd></div></dl>
<section><h3>邮件正文</h3><pre class="operator-text">${escapeHtml(state.mail.body_text || state.mail.body_html || '暂无正文')}</pre></section>
<section><div class="operator-section-heading"><h3>附件原件</h3><span class="operator-inline-note">${attachments.length} 个附件</span></div>${attachments.map(renderAttachment).join('') || '<div class="operator-inline-note">暂无附件。</div>'}</section>`;
}
function renderAttachment(item) {
const docs = item.documents || [];
const itemRecognition = (state.recognition?.attachments || []).find((row) => row.attachment_id === item.attachment_id);
const draft = state.ocrDrafts[item.attachment_id] || {};
const missing = itemRecognition?.missing_fields || [];
const names = fieldNames(itemRecognition?.extracted_fields, itemRecognition?.effective_fields, missing);
return `<article class="operator-attachment"><div class="operator-attachment__header"><div><strong>${escapeHtml(item.filename || item.attachment_id)}</strong><p class="operator-meta">OCR ${escapeHtml(itemRecognition?.ocr_status || '未记录')}</p></div><div class="operator-actions">${actionButton(PREVIEW_TYPES.has(String(item.media_type || '').toLowerCase()) ? '预览原件' : '下载原件', `file:${encodeURIComponent(item.attachment_id)}`)} </div></div>${missing.length ? `<div class="operator-warning">缺失字段:${escapeHtml(missing.join('、'))}</div>` : ''}<div class="operator-subsection"><h4>OCR 识别字段</h4>${renderFieldGrid(itemRecognition?.effective_fields || itemRecognition?.extracted_fields, draft, 'ocr', item.attachment_id, itemRecognition?.field_confidence || {})}<div class="operator-actions">${actionButton('保存', `save-ocr:${encodeURIComponent(item.attachment_id)}`, 'primary')}${actionButton('重试', docs[0]?.task_id ? `retry:${encodeURIComponent(docs[0].task_id)}` : 'noop')}</div></div></article>`;
}
function renderDocuments() {
if (!state.selectedMailId) {
renderEmpty(targets.documents, '请选择邮件后处理单据', '单据来自邮件附件关联记录。');
return;
}
if (!state.documents.length) {
renderEmpty(targets.documents, '当前邮件没有业务单据', '如果附件识别异常,可在上方查看原件并提交识别重试。');
return;
}
targets.documents.innerHTML = state.documents.map((document) => {
const taskId = document.task_id;
const nl = state.nl2sql[taskId];
const rule = state.rules[taskId];
const draft = state.nlDrafts[taskId] || {};
const ruleRows = rule?.rules || [];
const displayStatus = documentDisplayStatus(document);
const notificationType = documentNotificationType(document);
const recalculating = state.recalculatingTasks.has(taskId);
const encodedTaskId = encodeURIComponent(String(taskId || '').trim());
const notificationAction = notificationType
? `notice:${encodedTaskId}:${notificationType}`
: `notice:${encodedTaskId}`;
return `<article class="operator-document"><div class="operator-document__header"><div><strong>${escapeHtml(taskId)}</strong><p class="operator-meta">${escapeHtml(document.document_type || '单据')} · ${escapeHtml(document.fund_name || document.fund_code || '--')} · ${escapeHtml(document.application_no || '--')}</p></div><div>${tag(document.operator_decision || displayStatus.label, document.operator_decision === '确认异常' ? 'high' : displayStatus.tone)}</div></div><dl class="operator-kv"><div><dt>申请日期</dt><dd>${escapeHtml(value(document.application_date))}</dd></div><div><dt>申购金额 / 赎回份额</dt><dd>${escapeHtml(formatAmount(document.subscription_amount_yuan))} / ${escapeHtml(formatAmount(document.redemption_shares))}</dd></div><div><dt>机构</dt><dd>${escapeHtml(value(document.agency))}</dd></div><div><dt>基金代码</dt><dd>${escapeHtml(value(document.fund_code))}</dd></div></dl>
<div class="operator-subsection"><div class="operator-section-heading"><h3>NL2SQL 返回字段</h3><span class="operator-inline-note">${escapeHtml(nl?.updated_at ? formatDateTime(nl.updated_at, true) : '尚未核对')}</span></div>${nl?.error ? errorText({ message: nl.error }) : renderFieldGrid(nl?.effective_fields || nl?.fields, draft, 'nl', taskId, nl?.field_status || {})}<div class="operator-actions">${actionButton('保存', `save-nl:${encodeURIComponent(taskId)}`, 'primary')}${actionButton('进入 NL2SQL', `open-nl:${encodeURIComponent(taskId)}`)}</div></div>
<div class="operator-subsection"><div class="operator-section-heading"><h3>规则结果</h3>${rule ? tag(documentStatusLabel(rule.document_status)) : ''}</div>${rule?.error ? errorText({ message: rule.error }) : `<div class="operator-table-wrap"><table class="operator-table"><thead><tr><th>规则</th><th>结果</th><th>单据值</th><th>规则判断</th></tr></thead><tbody>${ruleRows.map((row) => `<tr><td>${escapeHtml(row.rule_name || row.rule_code || '--')}</td><td>${tag(row.result)}</td><td>${escapeHtml(renderDocumentValue(row))}</td><td>${renderRuleComparison(row)}</td></tr>`).join('') || '<tr><td colspan="4">暂无规则结果,请先执行 NL2SQL 核对。</td></tr>'}</tbody></table></div>`}<div class="operator-actions">${actionButton(recalculating ? '正在核对并判定规则' : '重新核对并判定规则', `recalculate:${encodeURIComponent(taskId)}`, 'primary', recalculating)}${actionButton('确认正常', `confirm:${encodeURIComponent(taskId)}:确认正常`)}${actionButton('确认异常', `confirm:${encodeURIComponent(taskId)}:确认异常`, 'danger')}${actionButton(notificationType ? `创建${NOTIFICATION_LABELS[notificationType]}` : '请先确认正常或异常', notificationAction, '', !notificationType)}</div></div></article>`;
}).join('');
}
function renderNotice() {
if (!state.notice) {
targets.noticePanel.hidden = true;
return;
}
targets.noticePanel.hidden = false;
targets.notice.innerHTML = `<div class="operator-form-grid"><label class="form-field"><span class="form-field__label">通知编号</span><input class="form-field__input" value="${escapeHtml(state.notice.notification_id)}" readonly></label><label class="form-field"><span class="form-field__label">关联单据</span><input class="form-field__input" value="${escapeHtml(state.noticeTaskId)}" readonly></label><label class="form-field"><span class="form-field__label">通知类型</span><select class="form-field__input" data-notice-type>${NOTIFICATION_TYPES.map(([key, label]) => `<option value="${key}"${key === state.noticeType ? ' selected' : ''}>${label}</option>`).join('')}</select></label></div><label class="form-field"><span class="form-field__label">最终发送正文</span><textarea class="form-field__input" rows="5" maxlength="10000" data-notice-content>${escapeHtml(state.noticeDraft)}</textarea></label><div class="operator-actions">${actionButton('发送通知', 'send-notice', 'primary')}${actionButton('清除通知', 'clear-notice')}</div>`;
}
function renderStatistics() {
const items = Array.isArray(state.stats?.items)
? state.stats.items
: state.stats ? [state.stats] : [];
const blocks = items.map((item) => `<article class="operator-result-card"><div class="operator-section-heading"><div><h3>${escapeHtml(value(item.fund_name, '未识别基金'))}</h3><p class="operator-meta">基金代码:${escapeHtml(value(item.fund_code))}</p></div><span class="operator-inline-note">${escapeHtml(value(item.application_date))}</span></div><dl class="operator-kv"><div><dt>申购金额 / 笔数</dt><dd>${escapeHtml(formatAmount(item.subscription_amount_yuan ?? '0'))} / ${escapeHtml(value(item.subscription_count, '0'))}</dd></div><div><dt>赎回份额 / 金额</dt><dd>${escapeHtml(formatAmount(item.redemption_shares ?? '0'))} / ${escapeHtml(formatAmount(item.redemption_amount_yuan))}</dd></div><div><dt>净流入 / 流出</dt><dd>${escapeHtml(formatAmount(item.net_flow_amount_yuan))}</dd></div><div><dt>最新净值</dt><dd>${escapeHtml(formatNav(item.latest_nav))}</dd></div></dl></article>`).join('');
targets.statistics.innerHTML = `<div class="operator-form-grid"><label class="form-field"><span class="form-field__label">基金代码(可选)</span><input class="form-field__input" data-stat-field="fundCode" value="${escapeHtml(state.statsForm.fundCode)}"></label><label class="form-field"><span class="form-field__label">申请日期</span><input class="form-field__input" type="date" data-stat-field="applicationDate" value="${escapeHtml(state.statsForm.applicationDate)}"></label></div>${state.stats ? (items.length ? `<p class="operator-inline-note">共 ${items.length} 只基金,每只基金单独汇总。</p><div class="operator-stack">${blocks}</div>` : '<div class="operator-inline-note">当天没有符合条件的清算单据。</div>') : ''}`;
}
function render() {
renderMetrics(); renderStatusTabs(); renderMailList(); renderDetail(); renderDocuments(); renderNotice(); renderStatistics();
}
/**
* 把识别接口返回的**单据标准字段**合并进 `state.documents`。
*
* 为什么必须合并:邮件详情的 `attachments[].documents[]` 只是一个**摘要**
* (`task_id` / `document_type` / `status` / `operator_decision`),
* 基金代码、申请日期、申购金额、机构这些字段**只在识别接口的返回里**
* (`_mail_detail` vs `_recognition_payload`)。只用摘要渲染,
* "单据核对与运营动作"里的单据信息就全是 `--`。
*
* 合并口径(**逐条按 task_id 匹配**,不是整段替换):
* - 两侧都有 → 识别侧字段优先,摘要里独有的键(status/operator_decision)保留;
* - 只有摘要侧 → 原样保留(不能因为识别接口没提到就丢掉单据);
* - 只有识别侧 → 也要出现(例如刚生成、邮件摘要还没刷新)。
*/
function syncDocumentsFromRecognition(payload) {
const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
if (!attachments.length || !state.mail) return;
const recognitionDocuments = new Map();
attachments.forEach((attachment) => {
(attachment.documents || []).forEach((document) => {
if (!document?.task_id) return;
recognitionDocuments.set(document.task_id, {
...document,
attachment_id: attachment.attachment_id,
});
});
});
if (!recognitionDocuments.size) return;
state.mail = {
...state.mail,
attachments: (state.mail.attachments || []).map((attachment) => {
const summaries = attachment.documents || [];
const merged = summaries.map((document) => {
const recognized = recognitionDocuments.get(document.task_id);
return recognized ? { ...document, ...recognized } : document;
});
const known = new Set(summaries.map((document) => document.task_id));
return {
...attachment,
documents: merged.concat(
[...recognitionDocuments.values()].filter(
(document) => document.attachment_id === attachment.attachment_id
&& !known.has(document.task_id),
),
),
};
}),
};
state.mailDetails[state.mail.mail_id] = state.mail;
state.documents = state.mail.attachments.flatMap((attachment) => (
attachment.documents || []
));
}
async function loadMail(mailId) {
const [mailResponse, recognitionResponse] = await Promise.all([
apiClient.get('OFFSITE_MAIL', { pathParams: { mailId } }),
apiClient.get('OFFSITE_RECOGNITION', { pathParams: { mailId } }).catch((error) => ({ data: { error: error.message, attachments: [] } })),
]);
state.selectedMailId = mailId;
state.mail = mailResponse.data || {};
state.mailDetails[mailId] = state.mail;
state.recognition = recognitionResponse.data || {};
state.ocrDrafts = Object.fromEntries((state.recognition.attachments || []).map((item) => [item.attachment_id, { ...(item.effective_fields || item.extracted_fields || {}) }]));
state.documents = (state.mail.attachments || []).flatMap((attachment) => (attachment.documents || []).map((document) => ({ ...document, attachment_id: document.attachment_id || attachment.attachment_id })));
// 邮件详情给的单据只是摘要,标准化字段在识别接口那一份里 ⇒ 这里必须再合并一次。
// 缺了这一步,"单据核对与运营动作"的单据信息(申请日期/申购金额/机构/基金代码)全是 `--`。
syncDocumentsFromRecognition(state.recognition);
const taskIds = [...new Set(state.documents.map((item) => item.task_id).filter(Boolean))];
const results = await Promise.all(taskIds.map(async (taskId) => {
const [nlResult, ruleResult] = await Promise.allSettled([
apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }),
apiClient.get('OFFSITE_RULE_RESULTS', { pathParams: { taskId } }),
]);
return { taskId, nl: nlResult.status === 'fulfilled' ? nlResult.value.data : { error: nlResult.reason?.message || '读取失败' }, rule: ruleResult.status === 'fulfilled' ? ruleResult.value.data : { error: ruleResult.reason?.message || '读取失败' } };
}));
state.nl2sql = Object.fromEntries(results.map(({ taskId, nl }) => [taskId, nl]));
state.nlDrafts = Object.fromEntries(results.map(({ taskId, nl }) => [taskId, { ...(nl.effective_fields || nl.fields || {}) }]));
state.rules = Object.fromEntries(results.map(({ taskId, rule }) => [taskId, rule]));
const first = state.documents.find((item) => item.fund_code && item.application_date);
if (first) {
state.statsForm.applicationDate = String(first.application_date).slice(0, 10);
}
render();
}
async function loadMailStatusDetails(mails) {
const candidates = mails.filter((mail) => String(mail.status || '') === 'processing');
await Promise.allSettled(candidates.map(async (mail) => {
const response = await apiClient.get('OFFSITE_MAIL', { pathParams: { mailId: mail.mail_id } });
state.mailDetails[mail.mail_id] = response.data || {};
}));
}
async function load() {
renderLoading(targets.list, 5); renderLoading(targets.detail, 3); renderLoading(targets.documents, 3);
try {
const [mails, mailbox] = await Promise.all([
apiClient.get('OFFSITE_MAILS', { query: { page: state.page, page_size: state.pageSize } }),
apiClient.get('OFFSITE_MAILBOX'),
]);
const payload = mails.data || {};
state.mails = Array.isArray(payload.items) ? payload.items : [];
state.total = Number(payload.total || 0);
state.mailbox = mailbox.data || {};
await loadMailStatusDetails(state.mails);
if (state.requestedMailId && state.mails.some((item) => item.mail_id === state.requestedMailId)) {
state.selectedMailId = state.requestedMailId;
} else if (!state.selectedMailId || !state.mails.some((item) => item.mail_id === state.selectedMailId)) {
state.selectedMailId = state.mails[0]?.mail_id || '';
}
if (state.selectedMailId) await loadMail(state.selectedMailId);
else { state.mail = null; state.documents = []; render(); }
} catch (error) {
apiClient.reportError(error); renderError(targets.list, error, load); renderError(targets.detail, error, load); renderError(targets.documents, error, load);
}
}
async function fileAction(attachmentId) {
const result = await apiClient.file('OFFSITE_ATTACHMENT_FILE', { pathParams: { attachmentId }, query: { disposition: 'inline' } });
const url = URL.createObjectURL(result.blob);
const opened = window.open(url, '_blank', 'noopener,noreferrer');
if (!opened) {
const link = document.createElement('a'); link.href = url; link.download = attachmentId; link.click();
}
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
}
/**
* 重新核对单个单据:触发 NL2SQL → 拉取返回字段 → 重新判定规则。
* 返回是否**全部成功**(`query_failed` 视为未成功,菜单里会提示,但不抛错)。
*
* 抽成函数是因为它有两个入口:面板上的"重新核对并判定规则"按钮,
* 以及保存 OCR 识别字段之后(见 `save-ocr:`)—— 两处必须走同一条路径,
* 否则"保存后字段不显示"这类不一致会再次出现。
*/
async function recalculateDocument(taskId) {
if (!taskId || state.recalculatingTasks.has(taskId)) return true;
state.recalculatingTasks.add(taskId);
render();
try {
const triggerResponse = await apiClient.post(
'OFFSITE_TRIGGER_NL2SQL',
{ operator_id: operatorId, manual_confirmed: true },
{ pathParams: { taskId } },
);
const [fieldsResponse, rulesResponse] = await Promise.all([
apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }),
apiClient.post(
'OFFSITE_RULE_RECALCULATE',
{ operator_id: operatorId },
{ pathParams: { taskId } },
),
]);
state.nl2sql[taskId] = fieldsResponse.data;
state.nlDrafts[taskId] = {
...(fieldsResponse.data?.effective_fields || fieldsResponse.data?.fields || {}),
};
state.rules[taskId] = rulesResponse.data;
return triggerResponse.data?.status !== 'query_failed';
} finally {
state.recalculatingTasks.delete(taskId);
render();
}
}
async function run(action) {
try {
if (action === 'refresh') { await load(); showToast('邮件列表已刷新'); return; }
if (action.startsWith('select-mail:')) {
await loadMail(decodeURIComponent(action.slice(12)));
return;
}
if (action === 'recover-mailbox') { await apiClient.post('OFFSITE_MAILBOX_RECOVER', { operator_id: operatorId }); await load(); showToast('收件游标已恢复'); return; }
if (action.startsWith('page:')) { const pages = Math.max(1, Math.ceil(state.total / state.pageSize)); state.page = Math.min(pages, Math.max(1, state.page + (action.endsWith('next') ? 1 : -1))); await load(); return; }
if (action.startsWith('delete-mail:')) {
const mailId = decodeURIComponent(action.slice(12));
if (!window.confirm('确认彻底删除这封邮件吗?邮件、附件、OCR、NL2SQL、规则、确认和通知记录都会删除,删除审计会保留。')) return;
await apiClient.post('OFFSITE_MAIL_DELETE', { operator_id: operatorId }, { pathParams: { mailId } });
state.selectedMailId = ''; await load(); showToast('邮件已删除'); return;
}
if (action.startsWith('file:')) { await fileAction(decodeURIComponent(action.slice(5))); return; }
if (action.startsWith('save-ocr:')) {
const attachmentId = decodeURIComponent(action.slice(9));
const response = await apiClient.post('OFFSITE_RECOGNITION_SAVE', { operator_id: operatorId, attachments: [{ attachment_id: attachmentId, fields: state.ocrDrafts[attachmentId] || {} }] }, { pathParams: { mailId: state.selectedMailId } });
state.recognition = response.data;
const saved = state.recognition.attachments?.find((item) => item.attachment_id === attachmentId);
state.ocrDrafts[attachmentId] = { ...(saved?.effective_fields || {}) };
syncDocumentsFromRecognition(state.recognition);
await loadMail(state.selectedMailId);
// ★ 保存识别字段 == 运营已人工确认这张单据的内容,因此顺手把**该附件关联的单据**
// 重新核对一次(触发 NL2SQL → 拉字段 → 重新判定规则)。
//
// 为什么必须在这里做:核对结果("单据核对与运营动作"里的 NL2SQL 返回字段与规则结果)
// 只在触发核对时才生成;保存识别字段本身不触发它。少了这一步,运营改完字段点保存后,
// 那两个区块要么停留在**上一次**核对的状态(例如仍是"申请前持有份额 null / 无法判断"),
// 要么整块是空的,必须再手动点一次"重新核对并判定规则"—— 而运营会以为"保存没生效"。
// 只核对本附件关联的单据,不动同一封邮件里的其它单据。
const recalcTasks = (state.documents || [])
.filter((document) => document.attachment_id === attachmentId)
.map((document) => document.task_id)
.filter(Boolean);
let recalcFailed = 0;
for (const taskId of recalcTasks) {
if (!await recalculateDocument(taskId)) recalcFailed += 1;
}
await loadMail(state.selectedMailId);
showToast(
recalcFailed ? 'OCR 识别字段已保存;核对未全部成功' : 'OCR 识别字段已保存并已重新核对',
recalcFailed ? 'error' : 'success',
);
return;
}
if (action.startsWith('save-nl:')) {
const taskId = decodeURIComponent(action.slice(8));
const response = await apiClient.post('OFFSITE_NL2SQL_FIELDS_SAVE', { operator_id: operatorId, fields: state.nlDrafts[taskId] || {} }, { pathParams: { taskId } });
state.nl2sql[taskId] = response.data;
state.nlDrafts[taskId] = { ...(response.data.effective_fields || response.data.fields || {}) };
await apiClient.post('OFFSITE_RULE_RECALCULATE', { operator_id: operatorId }, { pathParams: { taskId } });
await loadMail(state.selectedMailId);
showToast('NL2SQL 字段修正已保存'); return;
}
if (action.startsWith('retry:')) {
const taskId = decodeURIComponent(action.slice(6));
if (!window.confirm('是否重新对该文件进行 OCR 识别?')) return;
await apiClient.post('OFFSITE_RECOGNITION_RETRY', { operator_id: operatorId }, { pathParams: { taskId } });
await loadMail(state.selectedMailId); showToast('已提交识别重试'); return;
}
if (action.startsWith('recalculate:')) {
const taskId = decodeURIComponent(action.slice(12));
if (state.recalculatingTasks.has(taskId)) return;
const ok = await recalculateDocument(taskId);
showToast(ok ? '规则已重新核对并判定' : '已重新核对,但存在查询失败',
ok ? 'success' : 'error');
return;
}
if (action.startsWith('confirm:')) {
const [, encodedTask, decision] = action.split(':');
const taskId = decodeURIComponent(encodedTask).trim();
await apiClient.post('OFFSITE_CONFIRM', { decision, operator_id: operatorId }, { pathParams: { taskId } });
await loadMail(state.selectedMailId); showToast(`单据已${decision}`); return;
}
if (action.startsWith('open-nl:')) {
const taskId = decodeURIComponent(action.slice(8));
const params = new URLSearchParams({ task_id: taskId, mail_id: state.selectedMailId, page: String(state.page) });
window.location.href = `/portal/employee-operations/nl2sql/?${params.toString()}`;
return;
}
if (action.startsWith('notice:')) {
const actionPayload = action.slice(7);
const separator = actionPayload.lastIndexOf(':');
const encodedTaskId = separator >= 0 ? actionPayload.slice(0, separator) : actionPayload;
const noticeType = separator >= 0 ? actionPayload.slice(separator + 1) : '';
const taskId = decodeURIComponent(encodedTaskId).trim();
if (!noticeType) throw new Error('请先确认正常或确认异常后再创建通知');
const response = await apiClient.post('OFFSITE_NOTIFICATION_CREATE', { notification_type: noticeType, operator_id: operatorId }, { pathParams: { taskId } });
state.notice = response.data || {}; state.noticeType = noticeType; state.noticeTaskId = taskId; state.noticeDraft = `${taskId} 待发送${NOTIFICATION_LABELS[noticeType]}`; showToast(`${NOTIFICATION_LABELS[noticeType]}已创建`); render(); return;
}
if (action === 'send-notice') {
if (!state.notice?.notification_id) throw new Error('请先创建通知');
if (!window.confirm('确认发送这条通知吗?发送后将影响业务状态。')) return;
const response = await apiClient.post('OFFSITE_NOTIFICATION_SEND', { operator_id: operatorId, operator_confirmed: true, final_content: state.noticeDraft }, { pathParams: { notificationId: state.notice.notification_id } });
state.notice = { ...state.notice, ...response.data }; showToast(`通知状态:${response.data?.status || '已提交'}`); render(); return;
}
if (action === 'clear-notice') { state.notice = null; state.noticeTaskId = ''; render(); return; }
if (action === 'statistics') {
if (!state.statsForm.applicationDate) throw new Error('请填写申请日期');
const response = await apiClient.post('OFFSITE_SETTLEMENT_RECALCULATE', { fund_code: state.statsForm.fundCode || null, application_date: state.statsForm.applicationDate });
state.stats = response.data; showToast('清算统计已刷新'); render(); return;
}
} catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); }
}
root.addEventListener('click', (event) => {
const statusKey = event.target.closest('[data-mail-status]')?.dataset.mailStatus;
if (statusKey) {
state.activeMailStatus = statusKey;
render();
return;
}
const action = event.target.closest('[data-action]')?.dataset.action;
if (action) { event.preventDefault(); run(action); return; }
const mailId = event.target.closest('[data-mail]')?.dataset.mail;
if (mailId) { run(`select-mail:${encodeURIComponent(mailId)}`); }
});
root.addEventListener('input', (event) => {
const target = event.target;
if (target.matches('[data-ocr-field]')) { state.ocrDrafts[target.dataset.ocrField] ||= {}; state.ocrDrafts[target.dataset.ocrField][target.dataset.fieldName] = isNumericDisplayField(target.dataset.fieldName) || /净值|nav/i.test(target.dataset.fieldName) ? target.value.replace(/,/g, '') : target.value; }
if (target.matches('[data-nl-field]')) { state.nlDrafts[target.dataset.nlField] ||= {}; state.nlDrafts[target.dataset.nlField][target.dataset.fieldName] = isNumericDisplayField(target.dataset.fieldName) || /净值|nav/i.test(target.dataset.fieldName) ? target.value.replace(/,/g, '') : target.value; }
if (target.matches('[data-stat-field]')) { state.statsForm[target.dataset.statField] = target.value; }
if (target.matches('[data-notice-content]')) state.noticeDraft = target.value;
});
document.querySelector('[data-mail-refresh]').addEventListener('click', () => run('refresh'));
document.querySelector('[data-mailbox-recover]').addEventListener('click', () => run('recover-mailbox'));
document.querySelector('[data-stat-refresh]').addEventListener('click', () => run('statistics'));
load();
}