B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据
This commit is contained in:
+260
-24
@@ -1151,6 +1151,8 @@ function resetUpload() {
|
||||
$("#uploadForm")?.reset();
|
||||
if ($("#filePreview")) $("#filePreview").hidden = true;
|
||||
if ($("#parseResult")) $("#parseResult").hidden = true;
|
||||
if ($("#sheetReview")) $("#sheetReview").hidden = true;
|
||||
if ($("#sheetList")) $("#sheetList").replaceChildren();
|
||||
if ($("#dropzone")) $("#dropzone").hidden = false;
|
||||
if ($("#parseButton")) {
|
||||
$("#parseButton").disabled = true;
|
||||
@@ -1203,41 +1205,265 @@ async function parseFile() {
|
||||
result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" };
|
||||
}
|
||||
state.parseResult = result;
|
||||
const duplicated = result.status === "duplicate";
|
||||
const opaqueDuplicate = duplicated && !result.bank;
|
||||
const panel = $("#parseResult");
|
||||
panel.classList.toggle("is-exception", !parsed);
|
||||
$("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
|
||||
$("strong", panel).textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
|
||||
$("#parseSummary").textContent = parsed
|
||||
? opaqueDuplicate
|
||||
? "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。"
|
||||
: `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}${duplicated ? " · 重复上传,复用已有批次" : ""}`
|
||||
: `${result.message} 系统不会猜测模板或自动入账。`;
|
||||
panel.hidden = false;
|
||||
$("#parseButton span").textContent = parsed ? "确认导入" : "提交异常";
|
||||
$("#parseButton").disabled = false;
|
||||
$("#parseButton").dataset.stage = "confirm";
|
||||
renderParseResult(result, parsed);
|
||||
}
|
||||
|
||||
function confirmImport() {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `<td><strong>IMP-260806-019</strong></td><td>${$("#accountSelect").value}</td><td>${state.parseResult?.period_start || "待确认"}—${state.parseResult?.period_end || "待确认"}</td><td>${state.parseResult?.transactions ?? "—"}</td><td><span class="status warning">待计算</span></td><td><span class="status neutral">已解析</span></td><td>刚刚</td>`;
|
||||
$("#importRows")?.prepend(row);
|
||||
function renderParseResult(result, parsed) {
|
||||
const panel = $("#parseResult");
|
||||
if (!panel) return;
|
||||
const sheets = Array.isArray(result.sheets) ? result.sheets : [];
|
||||
const duplicated = result.status === "duplicate";
|
||||
const opaque = duplicated && !sheets.length;
|
||||
panel.classList.toggle("is-exception", !parsed);
|
||||
$("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
|
||||
$("#parseTitle").textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
|
||||
const pendingCount = sheets.filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
|
||||
const exceptionCount = sheets.filter((s) => s.outcome === "exception").length;
|
||||
const ignoredCount = sheets.filter((s) => s.outcome === "ignored" || s.review_status === "ignored").length;
|
||||
let summary;
|
||||
if (opaque) {
|
||||
summary = "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。";
|
||||
} else if (sheets.length) {
|
||||
summary = `${sheets.length} 个工作表:${pendingCount} 个待确认${exceptionCount ? `、${exceptionCount} 个异常` : ""}${ignoredCount ? `、${ignoredCount} 个忽略` : ""}。解析成功不等于业务确认。`;
|
||||
} else {
|
||||
summary = `${result.message || ""} 系统不会猜测模板或自动入账。`;
|
||||
}
|
||||
$("#parseSummary").textContent = summary;
|
||||
panel.hidden = false;
|
||||
renderSheetList(sheets, result.batch_id);
|
||||
|
||||
const button = $("#parseButton");
|
||||
button.dataset.stage = "confirm";
|
||||
if (!parsed) {
|
||||
button.querySelector("span").textContent = "关闭";
|
||||
} else if (opaque) {
|
||||
button.querySelector("span").textContent = "完成";
|
||||
} else if (sheets.length) {
|
||||
button.querySelector("span").textContent = pendingCount ? `确认全部(${pendingCount} 个待确认)` : "完成";
|
||||
} else {
|
||||
button.querySelector("span").textContent = "完成";
|
||||
}
|
||||
}
|
||||
|
||||
function sheetStatusMeta(sheet) {
|
||||
if (sheet.review_status === "confirmed") return { className: "success", label: "已确认" };
|
||||
if (sheet.review_status === "ignored") return { className: "neutral", label: "已忽略" };
|
||||
if (sheet.outcome === "exception") return { className: "danger", label: "异常待处理" };
|
||||
if (sheet.outcome === "ignored") return { className: "neutral", label: "空表忽略" };
|
||||
return { className: "warning", label: "待确认" };
|
||||
}
|
||||
|
||||
function renderSheetList(sheets, batchId) {
|
||||
const wrap = $("#sheetReview");
|
||||
const list = $("#sheetList");
|
||||
if (!wrap || !list || !sheets.length) {
|
||||
if (wrap) wrap.hidden = true;
|
||||
return;
|
||||
}
|
||||
wrap.hidden = false;
|
||||
list.replaceChildren(...sheets.map((sheet) => buildSheetItem(sheet, batchId)));
|
||||
}
|
||||
|
||||
function buildSheetItem(sheet, batchId) {
|
||||
const item = document.createElement("article");
|
||||
item.className = "sheet-item";
|
||||
if (sheet.review_status === "pending") item.classList.add("is-pending");
|
||||
const meta = sheetStatusMeta(sheet);
|
||||
const head = document.createElement("div");
|
||||
head.className = "sheet-item-head";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = sheet.sheet_name;
|
||||
const badge = document.createElement("em");
|
||||
badge.className = `status ${meta.className}`;
|
||||
badge.textContent = meta.label;
|
||||
head.append(name, badge);
|
||||
const details = document.createElement("p");
|
||||
details.className = "sheet-item-meta";
|
||||
if (sheet.outcome === "parsed" && sheet.bank) {
|
||||
const period = sheet.period_start ? ` · ${sheet.period_start}—${sheet.period_end}` : "";
|
||||
details.textContent = `${sheet.bank} · ${sheet.transactions} 条明细${period}`;
|
||||
} else if (sheet.message) {
|
||||
details.textContent = sheet.message;
|
||||
} else {
|
||||
details.textContent = "空工作表。";
|
||||
}
|
||||
if (sheet.review_reason) {
|
||||
details.textContent += ` · 原因:${sheet.review_reason}`;
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.append(head, details);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "sheet-item-actions";
|
||||
if (sheet.review_status === "pending") {
|
||||
if (sheet.outcome === "parsed") {
|
||||
const confirmButton = document.createElement("button");
|
||||
confirmButton.type = "button";
|
||||
confirmButton.className = "text-button";
|
||||
confirmButton.textContent = "确认";
|
||||
confirmButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "confirm"));
|
||||
actions.append(confirmButton);
|
||||
}
|
||||
const ignoreButton = document.createElement("button");
|
||||
ignoreButton.type = "button";
|
||||
ignoreButton.className = "text-button";
|
||||
ignoreButton.textContent = "忽略";
|
||||
ignoreButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "ignore"));
|
||||
actions.append(ignoreButton);
|
||||
}
|
||||
item.append(body, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function sheetReviewAction(batchId, sheetName, decision) {
|
||||
const payload = { sheets: [sheetName] };
|
||||
if (decision === "ignore") {
|
||||
const reason = (window.prompt("请填写忽略原因(必填):", "") || "").trim();
|
||||
if (!reason) {
|
||||
showToast("忽略未提交", "必须填写忽略原因");
|
||||
return;
|
||||
}
|
||||
payload.reason = reason;
|
||||
}
|
||||
const response = await fetch(`/api/batches/${batchId}/${decision}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("操作失败", result?.message || "请稍后重试");
|
||||
return;
|
||||
}
|
||||
showToast(decision === "confirm" ? "工作表已确认" : "工作表已忽略", `${sheetName}`);
|
||||
await refreshAfterSheetAction(result, batchId);
|
||||
}
|
||||
|
||||
async function refreshAfterSheetAction(result, batchId) {
|
||||
if (Array.isArray(result.sheets)) renderSheetList(result.sheets, batchId);
|
||||
await loadImportBatches();
|
||||
const button = $("#parseButton");
|
||||
if (!button) return;
|
||||
const pending = (result.sheets || []).filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
|
||||
if (pending === 0 && (result.sheets || []).length) {
|
||||
button.querySelector("span").textContent = "完成";
|
||||
button.dataset.stage = "done";
|
||||
} else {
|
||||
button.querySelector("span").textContent = `确认全部(${pending} 个待确认)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
const result = state.parseResult;
|
||||
const batchId = result?.batch_id;
|
||||
const sheets = Array.isArray(result?.sheets) ? result.sheets : [];
|
||||
const pending = sheets
|
||||
.filter((s) => s.outcome === "parsed" && s.review_status === "pending")
|
||||
.map((s) => s.sheet_name);
|
||||
if (!pending.length) {
|
||||
$("#uploadDialog").close();
|
||||
await loadImportBatches();
|
||||
showView("upload");
|
||||
return;
|
||||
}
|
||||
const button = $("#parseButton");
|
||||
button.disabled = true;
|
||||
button.querySelector("span").textContent = "正在确认...";
|
||||
const response = await fetch(`/api/batches/${batchId}/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sheets: pending }),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const outcome = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
button.disabled = false;
|
||||
button.querySelector("span").textContent = "确认失败,点击重试";
|
||||
showToast("确认失败", outcome?.message || "请稍后重试");
|
||||
return;
|
||||
}
|
||||
button.disabled = false;
|
||||
if (Array.isArray(outcome.sheets)) renderSheetList(outcome.sheets, batchId);
|
||||
await loadImportBatches();
|
||||
$("#uploadDialog").close();
|
||||
showView("upload");
|
||||
showToast("流水已进入归集队列", "原始文件和解析结果已保留");
|
||||
showToast("流水已确认", `${outcome.updated.length} 个工作表已确认;未确认的工作表不参与计算`);
|
||||
}
|
||||
|
||||
function submitImportException() {
|
||||
$("#uploadDialog").close();
|
||||
showView("upload");
|
||||
showToast("解析异常已提交", `${state.selectedFile?.name || "该文件"} 未入账,等待总账或模板维护人员处理`);
|
||||
showToast("解析异常未入账", `${state.selectedFile?.name || "该文件"} 不会进入匹配与计算,请核对模板后重新导出`);
|
||||
}
|
||||
|
||||
function renderBatchRow(batch) {
|
||||
const row = document.createElement("tr");
|
||||
const idCell = document.createElement("td");
|
||||
const id = document.createElement("strong");
|
||||
id.textContent = `IMP-${String(batch.id).padStart(6, "0")}`;
|
||||
const file = document.createElement("small");
|
||||
file.textContent = batch.original_filename || "";
|
||||
idCell.append(id, file);
|
||||
|
||||
const bank = document.createElement("td");
|
||||
bank.textContent = batch.bank_name || "—";
|
||||
|
||||
const period = document.createElement("td");
|
||||
period.textContent = batch.period_start && batch.period_end
|
||||
? `${batch.period_start}—${batch.period_end}`
|
||||
: "—";
|
||||
|
||||
const count = document.createElement("td");
|
||||
count.className = "number";
|
||||
count.textContent = `${batch.confirmed_transactions ?? 0} 笔`;
|
||||
|
||||
const coverage = document.createElement("td");
|
||||
const coverageStatus = batch.status === "exception"
|
||||
? { className: "danger", label: "未导入" }
|
||||
: batch.pending_sheets > 0
|
||||
? { className: "warning", label: `${batch.pending_sheets} 个待确认` }
|
||||
: batch.confirmed_sheets > 0
|
||||
? { className: "success", label: "已确认" }
|
||||
: { className: "neutral", label: "待处理" };
|
||||
coverage.innerHTML = `<span class="status ${coverageStatus.className}">${coverageStatus.label}</span>`;
|
||||
|
||||
const parseState = document.createElement("td");
|
||||
const statusParts = [];
|
||||
if (batch.exception_sheets > 0) statusParts.push(`${batch.exception_sheets} 个异常`);
|
||||
if (batch.ignored_sheets > 0) statusParts.push(`${batch.ignored_sheets} 个忽略`);
|
||||
parseState.textContent = statusParts.length ? statusParts.join("、") : "解析成功";
|
||||
|
||||
const time = document.createElement("td");
|
||||
time.textContent = String(batch.created_at || "").slice(0, 16).replace("T", " ");
|
||||
|
||||
row.append(idCell, bank, period, count, coverage, parseState, time);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function loadImportBatches() {
|
||||
const tbody = $("#importRows");
|
||||
if (!tbody) return;
|
||||
const response = await fetch("/api/batches").catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
const batches = Array.isArray(result?.batches) ? result.batches : [];
|
||||
if (batches.length) tbody.replaceChildren(...batches.map(renderBatchRow));
|
||||
}
|
||||
|
||||
function initCompany() {
|
||||
renderCompanyManualRecords();
|
||||
loadCompanyAccounts();
|
||||
loadImportBatches();
|
||||
const uploadDialog = $("#uploadDialog");
|
||||
$$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.showModal()));
|
||||
$$('[data-close-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.close()));
|
||||
@@ -1260,9 +1486,19 @@ function initCompany() {
|
||||
}
|
||||
$("#uploadForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if ($("#parseButton").dataset.stage === "confirm") {
|
||||
if (["parsed", "duplicate"].includes(state.parseResult?.status)) confirmImport();
|
||||
else submitImportException();
|
||||
const stage = $("#parseButton").dataset.stage;
|
||||
if (stage === "confirm" || stage === "done") {
|
||||
const result = state.parseResult;
|
||||
const parsed = result && ["parsed", "duplicate"].includes(result.status);
|
||||
if (parsed && stage === "confirm") {
|
||||
await confirmImport();
|
||||
} else if (parsed) {
|
||||
$("#uploadDialog").close();
|
||||
showView("upload");
|
||||
await loadImportBatches();
|
||||
} else {
|
||||
submitImportException();
|
||||
}
|
||||
return;
|
||||
}
|
||||
$("#parseButton").disabled = true;
|
||||
|
||||
+2
-2
@@ -129,7 +129,7 @@
|
||||
|
||||
<section class="app-view" data-page="upload">
|
||||
<header class="page-heading"><div><h1>流水导入</h1><p>上传 A公司银行账户流水,系统按表头识别银行模板</p></div><button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button></header>
|
||||
<section class="panel"><div class="table-summary"><span>最近导入批次</span><span>原始文件与解析结果将永久保留</span></div><div class="table-scroll"><table class="data-table"><thead><tr><th>批次</th><th>银行账户</th><th>流水期间</th><th>明细数</th><th>覆盖状态</th><th>解析状态</th><th>上传时间</th></tr></thead><tbody id="importRows"><tr><td><strong>IMP-260806-018</strong></td><td>中信银行 · 5316</td><td>07.01—07.31</td><td>128</td><td><span class="status success">连续</span></td><td><span class="status success">已确认</span></td><td>今天 09:42</td></tr><tr><td><strong>IMP-260731-012</strong></td><td>工商银行 · 9481</td><td>06.01—06.30</td><td>96</td><td><span class="status danger">后续断档</span></td><td><span class="status success">已确认</span></td><td>07.31 16:18</td></tr></tbody></table></div></section>
|
||||
<section class="panel"><div class="table-summary"><span>最近导入批次</span><span>仅已确认工作表参与匹配与计算</span></div><div class="table-scroll"><table class="data-table"><thead><tr><th>批次</th><th>银行账户</th><th>流水期间</th><th>明细数</th><th>覆盖状态</th><th>解析状态</th><th>上传时间</th></tr></thead><tbody id="importRows"></tbody></table></div></section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="manual">
|
||||
@@ -184,7 +184,7 @@
|
||||
<dialog id="uploadDialog" class="dialog upload-dialog">
|
||||
<form id="uploadForm">
|
||||
<header><div><h2>上传银行流水</h2><p>A公司 · 系统将识别表头与银行模板</p></div><button type="button" class="icon-button" data-close-upload aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
|
||||
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>文件解析完成</strong><p id="parseSummary"></p></span></div></div>
|
||||
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong id="parseTitle">文件解析完成</strong><p id="parseSummary"></p></span></div><div class="sheet-review" id="sheetReview" hidden><h3>工作表处理</h3><p class="sheet-review-hint">解析成功不等于业务确认:只有勾选并确认的工作表才会进入后续匹配与计算。</p><div class="sheet-list" id="sheetList" aria-live="polite"></div></div></div>
|
||||
<footer><button type="button" class="button secondary" data-close-upload>取消</button><button class="button primary" id="parseButton" type="submit" disabled><span>开始解析</span></button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -412,6 +412,19 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
|
||||
.parse-result p { color: #9bd7b6; }
|
||||
.parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); }
|
||||
.parse-result.is-exception p { color: #e7bd78; }
|
||||
.sheet-review { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); }
|
||||
.sheet-review h3 { font-size: 14px; }
|
||||
.sheet-review-hint { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
|
||||
.sheet-list { display: grid; gap: 8px; margin-top: 10px; max-height: 220px; overflow-y: auto; }
|
||||
.sheet-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
|
||||
.sheet-item.is-pending { border-color: rgba(255, 188, 82, 0.45); }
|
||||
.sheet-item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.sheet-item-head strong { font-size: 13px; }
|
||||
.sheet-item-head small { color: var(--color-ink-muted); }
|
||||
.sheet-item-meta { margin-top: 5px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
|
||||
.sheet-item-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.sheet-item-actions .text-button { font-size: 12px; padding: 4px 8px; }
|
||||
.sheet-item-reason { margin-top: 6px; padding: 6px 8px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); color: var(--color-ink-muted); font-size: 12px; }
|
||||
.toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 200; display: grid; gap: 8px; }
|
||||
.toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 20, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
|
||||
.toast strong, .toast small { display: block; }
|
||||
|
||||
Reference in New Issue
Block a user