B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据

This commit is contained in:
腾讯WorkBuddy
2026-08-17 01:00:19 +08:00
parent 7f1a93f6a6
commit 486842963e
15 changed files with 2019 additions and 183 deletions
+260 -24
View File
@@ -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;