const $ = (selector, scope = document) => scope.querySelector(selector); const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)]; const portal = document.body.dataset.portal || "entry"; const viewNames = portal === "admin" ? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" } : { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" }; const storageKeys = { manual: "ledger-demo-manual-records", }; const accountStatusLabels = { pending: "待复核", active: "已启用", returned: "已退回", disabled: "已停用", }; const state = { currentView: portal === "admin" ? "dashboard" : "workspace", selectedFile: null, parseResult: null, }; const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); function animateView(view, { initial = false } = {}) { if (!view || motionQuery.matches || typeof view.animate !== "function") return; if (!initial) { view.getAnimations().forEach((animation) => animation.cancel()); view.animate( [{ opacity: 0.84, transform: "translateY(5px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 180, easing: "cubic-bezier(.22,1,.36,1)" }, ); return; } const selectors = [ ".page-head", ".stat-card", ".card", ".notice", ".list-row", ".filters", ]; const elements = [...new Set(selectors.flatMap((selector) => [...view.querySelectorAll(selector)]))]; elements.forEach((element, index) => { element.getAnimations().forEach((animation) => animation.cancel()); const keyframes = [ { opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` }, { opacity: 1, transform: "translateY(0)" }, ]; element.animate(keyframes, { duration: 440, delay: Math.min(index * 38, 260), easing: "cubic-bezier(.22,1,.36,1)", fill: "both", }); }); } function initMotion() { if (motionQuery.matches) return; animateView($(".app-view.is-active"), { initial: true }); $$(".side-nav a", $("#sidebar") || document).forEach((item, index) => { item.animate( [{ opacity: 0, transform: "translateX(-8px)" }, { opacity: 1, transform: "translateX(0)" }], { duration: 360, delay: 90 + index * 28, easing: "cubic-bezier(.22,1,.36,1)", fill: "both" }, ); }); } function readStoredRecords(key) { try { const value = JSON.parse(localStorage.getItem(key) || "[]"); return Array.isArray(value) ? value : []; } catch { return []; } } function writeStoredRecords(key, records) { try { localStorage.setItem(key, JSON.stringify(records)); return true; } catch { showToast("本机演示数据保存失败", "请检查浏览器是否允许本地存储", "danger"); return false; } } function recordStatus(status) { if (["已启用", "已确认"].includes(status)) return { className: "success", label: status }; if (status === "已退回") return { className: "danger", label: status }; if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status }; return { className: "warning", label: status || "待复核" }; } function accountStatusLabel(status) { return accountStatusLabels[status] || "待复核"; } function pillClass(statusClass) { return { success: "pill-success", danger: "pill-danger", warning: "pill-warn", neutral: "pill-muted", info: "pill-info" }[statusClass] || "pill-muted"; } function accountTail(masked) { return String(masked || "").replace(/^\*+/, ""); } function formatCurrency(value) { return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function showTableLoading(tbody, cols = 6) { if (!tbody) return; tbody.innerHTML = `加载中…`; } function showTableError(tbody, cols = 6) { if (!tbody) return; tbody.innerHTML = `加载失败,请稍后重试`; } function showToast(title, detail = "", kind = "info") { const region = $("#toastRegion"); if (!region) return; const toast = document.createElement("div"); toast.className = `toast ${["success", "warn", "danger", "info"].includes(kind) ? kind : "info"}`; toast.setAttribute("role", "status"); const dot = document.createElement("span"); dot.className = "t-dot"; const body = document.createElement("div"); body.className = "t-body"; const heading = document.createElement("div"); heading.className = "t-title"; heading.textContent = title; body.append(heading); if (detail) { const description = document.createElement("div"); description.className = "t-detail"; description.textContent = detail; body.append(description); } toast.append(dot, body); region.append(toast); window.setTimeout(() => { toast.style.opacity = "0"; toast.style.transition = "opacity 0.2s ease"; window.setTimeout(() => toast.remove(), 200); }, 3400); } function closeNavigation({ restoreFocus = false } = {}) { const sidebar = $("#sidebar"); if (!sidebar) return; const wasOpen = sidebar.classList.contains("is-open"); sidebar.classList.remove("is-open"); $$(".menu-button").forEach((button) => { button.setAttribute("aria-expanded", "false"); button.setAttribute("aria-label", "打开导航"); }); if (restoreFocus && wasOpen) $(".menu-button")?.focus(); } function showView(view) { if (!viewNames[view]) return; const navigationWasOpen = $("#sidebar")?.classList.contains("is-open"); state.currentView = view; $$(".app-view").forEach((page) => page.classList.toggle("is-active", page.dataset.page === view)); $$(".side-nav a[data-view]").forEach((item) => { const active = item.dataset.view === view; item.classList.toggle("active", active); if (active) item.setAttribute("aria-current", "page"); else item.removeAttribute("aria-current"); }); const title = $("#currentViewName"); if (title) title.textContent = viewNames[view]; closeNavigation({ restoreFocus: navigationWasOpen }); const activeView = $(`.app-view[data-page="${view}"]`); requestAnimationFrame(() => animateView(activeView)); window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" }); } const detailContent = { "admin-gap": { tag: ["danger", "高风险"], title: "金牛置业 · 中行账户断档", desc: "中国银行尾号 8821 缺少 07-06 至 07-16 流水,已影响 7 月结账。", fields: [["公司", "金牛置业"], ["账户", "中国银行 · 尾号 8821"], ["缺口期间", "2026-07-06 — 07-16 · 11 天"], ["影响", "7 月结账 · 与金牛煤业 320 万往来无法归集"], ["当前状态", "阻断结账"]], tip: "先向金牛置业出纳发送补传提醒,补齐流水后到审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] }, "admin-unsubmitted": { tag: ["danger", "高风险"], title: "金牛农业 7 月未提交流水", desc: "全部账户 7 月流水均未提交,已发 2 次系统提醒。", fields: [["公司", "金牛农业"], ["账户", "全部账户"], ["已提醒", "2 次 · 最近一次 08-18"], ["当前状态", "未响应"]], tip: "可再次发送提醒,或在提醒管理里查看已发出的提醒记录。", action: ["去提醒管理", "reminders"] }, "admin-unilateral": { tag: ["warning", "中风险"], title: "金牛新能源 ↔ 金牛贸易 单边流水", desc: "3 笔单边流水合计 486 万元,待对方选择银行流水佐证。", fields: [["本方", "金牛新能源"], ["对方", "金牛贸易"], ["笔数 / 金额", "3 笔 · 486 万元"], ["当前状态", "待对方证据"]], tip: "提醒新能源侧在往来确认中选择对方银行流水佐证后提交。", action: ["去审核中心匹配", "audit"] }, "admin-subject": { tag: ["warning", "中风险"], title: "手工记录待确认往来科目", desc: "5 笔手工记录应收 / 其他应收待判定,涉及煤业、物流、贸易。", fields: [["待确认", "应收 / 其他应收"], ["涉及公司", "煤业、物流、贸易"], ["笔数", "5 笔"]], tip: "科目只按确定性规则建议,拿不准时选「其他应收」并注明依据。", action: ["去审核中心复核", "audit"] }, "task-match": { tag: ["danger", "阻断"], title: "确认 3 笔单边流水", desc: "选择对方银行流水作为证据后提交确认。", fields: [["笔数", "3 笔"], ["处理方式", "选择对方银行流水佐证"], ["关联", "7 月结账阻断项"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] }, "task-subject": { tag: ["danger", "阻断"], title: "确认 2 笔其他应收科目", desc: "核对后改判或维持原科目。", fields: [["笔数", "2 笔"], ["处理方式", "改判或维持原科目"], ["关联", "7 月结账阻断项"]], tip: "科目只按确定性规则建议,拿不准时选「其他应收」并注明依据。", action: ["去手工记录", "manual"] }, "task-upload": { tag: ["danger", "阻断"], title: "补传交行 7710 账户流水", desc: "账户已登记,待总行审核通过后即可导入。", fields: [["账户", "交通银行 · 尾号 7710"], ["状态", "待审核"], ["处理", "审核通过后上传 7 月流水"]], tip: "账户审核通过后,从交行网银导出 7 月流水直接上传。", action: ["去查看账户", "accounts"] }, "task-reconcile": { tag: ["muted", "一般"], title: "核对与金牛置业 320 万往来", desc: "置业 8821 账户 7 月流水断档,需人工核对。", fields: [["对方", "金牛置业"], ["金额", "320 万元"], ["原因", "置业 8821 账户流水断档"]], tip: "先核对双方流水日期与金额是否一致,再决定是否提交确认。", action: ["去流水管理", "flows"] }, }; function initDetailDrawer() { const triggers = $$("[data-detail]"); if (!triggers.length) return; const drawer = document.createElement("aside"); drawer.className = "drawer"; drawer.id = "detailDrawer"; drawer.setAttribute("aria-label", "事项详情"); drawer.innerHTML = `

`; document.body.append(drawer); let lastTrigger = null; function closeDrawer({ restoreFocus = true } = {}) { drawer.classList.remove("is-open"); if (restoreFocus && lastTrigger) lastTrigger.focus(); } function openDetail(id, trigger) { const item = detailContent[id]; if (!item) return; lastTrigger = trigger; const tag = $("#detailTag", drawer); tag.className = `pill ${pillClass(item.tag[0])}`; tag.textContent = item.tag[1]; $("#detailTitle", drawer).textContent = item.title; $("#detailDesc", drawer).textContent = item.desc; const fields = $("#detailFields", drawer); fields.replaceChildren(...item.fields.flatMap(([label, value]) => { const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); dd.textContent = value; return [dt, dd]; })); const tip = $("#detailTip", drawer); tip.replaceChildren(); const tipHeading = document.createElement("strong"); tipHeading.textContent = "处理建议"; tip.append(tipHeading, document.createTextNode(item.tip)); const action = $("#detailAction", drawer); action.textContent = item.action[0]; action.onclick = () => { closeDrawer({ restoreFocus: false }); if (item.action[1] === "upload") $("[data-open-upload]")?.click(); else showView(item.action[1]); }; drawer.classList.add("is-open"); $("[data-close-detail]", drawer).focus(); } $$("[data-close-detail]", drawer).forEach((button) => button.addEventListener("click", () => closeDrawer())); drawer.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.stopPropagation(); closeDrawer(); } }); triggers.forEach((element) => { element.addEventListener("click", (event) => { const innerButton = event.target.closest("button"); if (innerButton && innerButton !== element) return; openDetail(element.dataset.detail, element); }); if (element.tagName !== "BUTTON") { element.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openDetail(element.dataset.detail, element); } }); } }); } function initEntry() { const form = $("#loginForm"); if (!form) return; if (!motionQuery.matches) { [ [$(".entry-form-wrap"), 0], [$(".entry-statement h1"), 60], [$(".entry-statement p"), 120], [$(".entry-figure"), 120], ].forEach(([element, delay]) => { if (!element) return; element.animate( [{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 300, delay, easing: "cubic-bezier(.16,1,.3,1)", fill: "both" }, ); }); } const roleInputs = $$('input[name="role"]', form); const username = $('input[name="username"]', form); const password = $('input[name="password"]', form); const action = $("#loginAction"); const errorBox = $("#loginError"); const changeSection = $("#changePassword"); let pendingRole = null; const submitButton = $('button[type="submit"]', form); function setLoading(loading) { submitButton.disabled = loading; submitButton.classList.toggle("is-loading", loading); $$("input, button", form).forEach((el) => { if (el !== submitButton) el.disabled = loading; }); if (loading) { action.textContent = "登录中…"; } else if (pendingRole) { action.textContent = "设置新密码并进入"; } else { updateRole(); } } function showError(message) { errorBox.textContent = message; errorBox.hidden = false; } function updateRole() { const role = $('input[name="role"]:checked', form).value; action.textContent = role === "admin" ? "进入总账管理端" : "进入公司业务端"; } roleInputs.forEach((input) => input.addEventListener("change", updateRole)); $("#togglePassword").addEventListener("click", (event) => { const visible = password.type === "text"; password.type = visible ? "password" : "text"; event.currentTarget.setAttribute("aria-label", visible ? "显示密码" : "隐藏密码"); event.currentTarget.title = visible ? "显示密码" : "隐藏密码"; }); form.addEventListener("submit", async (event) => { event.preventDefault(); errorBox.hidden = true; setLoading(true); const role = pendingRole || $('input[name="role"]:checked', form).value; if (pendingRole) { const newPassword = $('input[name="new_password"]', form).value; const confirmPassword = $('input[name="confirm_password"]', form).value; if (newPassword !== confirmPassword) { showError("两次输入的新密码不一致。"); setLoading(false); return; } const changeResponse = await fetch("/api/password/change", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ old_password: password.value, new_password: newPassword }), }).catch(() => null); const changeResult = await changeResponse?.json().catch(() => ({})); if (!changeResponse || !changeResponse.ok) { showError(changeResult?.message || "修改密码失败,请稍后重试。"); setLoading(false); return; } window.location.href = role === "admin" ? "admin.html" : "company.html"; return; } const response = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username.value.trim(), password: password.value, portal: role }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showError(result?.message || "登录服务暂时不可用,请稍后重试。"); setLoading(false); return; } if (result.must_change_password) { pendingRole = role; changeSection.hidden = false; setLoading(false); $('input[name="new_password"]', form).focus(); return; } window.location.href = role === "admin" ? "admin.html" : "company.html"; }); } async function initAuthGuard() { if (portal === "entry") return true; try { const response = await fetch("/api/me"); if (response.status === 401) { window.location.href = "index.html"; return false; } const me = await response.json(); if (!response.ok || me.role !== portal) { window.location.href = "index.html"; return false; } state.me = me; applyCompanyIdentity(me); return true; } catch { return true; } } function applyCompanyIdentity(me) { if (!me) return; const row = $(".side-foot .user-row"); if (row) { const name = me.username || (portal === "company" ? (me.company_name || "公司用户") : "系统管理员"); const avatar = $(".avatar", row); if (avatar) avatar.textContent = name.slice(0, 1); const nameEl = $(".user-name", row); if (nameEl) nameEl.textContent = name; const metaEl = $(".user-meta", row); if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "集团总账会计"; } // The company portal always shows the session-bound company in page copy. if (portal === "company" && me.company_name) { $$(".company-identity").forEach((el) => { el.textContent = me.company_name; }); } } function initShell() { $$(".topbar").forEach((bar) => { if (bar.querySelector(".menu-button")) return; const button = document.createElement("button"); button.type = "button"; button.className = "icon-button menu-button"; button.setAttribute("aria-label", "打开导航"); button.setAttribute("aria-expanded", "false"); button.setAttribute("aria-controls", "sidebar"); button.title = "打开导航"; button.innerHTML = ''; bar.prepend(button); }); $$(".menu-button").forEach((menuButton) => { menuButton.addEventListener("click", () => { const open = $("#sidebar").classList.toggle("is-open"); menuButton.setAttribute("aria-expanded", String(open)); menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航"); }); }); $$(".side-nav a").forEach((item) => { const label = $("span", item)?.textContent.trim(); if (label) { item.setAttribute("aria-label", label); item.title = label; } }); $$("[data-view]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.view); })); $$("[data-view-link]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.viewLink); })); $$(".logout").forEach((control) => control.addEventListener("click", async (event) => { event.preventDefault(); try { await fetch("/api/logout", { method: "POST" }); } catch { /* 网络异常时仍然回到登录页 */ } window.location.href = "index.html"; })); $$("[data-metric-link]").forEach((card) => { const activate = () => showView(card.dataset.metricLink); card.addEventListener("click", activate); card.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } }); }); $$("[data-metric-action=\"upload\"]").forEach((card) => { const activate = () => $("[data-open-upload]")?.click(); card.addEventListener("click", activate); card.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } }); }); // Delegated: company table rows are rendered from the API after init. document.addEventListener("click", (event) => { const toastButton = event.target.closest("[data-toast]"); if (toastButton) showToast(toastButton.dataset.toast); }); $(".side-nav a[data-view].active")?.setAttribute("aria-current", "page"); document.addEventListener("click", (event) => { if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true }); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeNavigation({ restoreFocus: true }); }); initDetailDrawer(); initMotion(); } function pairData(from, to, endDate) { const companies = ["A公司", "B公司", "C公司", "D公司", "E公司", "F公司"]; const fromIndex = companies.indexOf(from) + 1; const toIndex = companies.indexOf(to) + 1; const low = Math.min(fromIndex, toIndex); const high = Math.max(fromIndex, toIndex); const seed = low * 13 + high * 7; const canonical = { receivable: 280 + seed * 18, otherReceivable: 120 + seed * 9, payable: 160 + ((seed * 11) % 720), otherPayable: 80 + ((seed * 5) % 360), }; const reversed = fromIndex > toIndex; const receivable = reversed ? canonical.payable : canonical.receivable; const otherReceivable = reversed ? canonical.otherPayable : canonical.otherReceivable; const payable = reversed ? canonical.receivable : canonical.payable; const otherPayable = reversed ? canonical.otherReceivable : canonical.otherPayable; const debit = receivable + otherReceivable; const credit = payable + otherPayable; const opening = ((fromIndex + toIndex) % 3) * 60 * (reversed ? -1 : 1); const final = opening + debit - credit; const end = new Date(`${endDate}T12:00:00`); const dateBefore = (days) => { const date = new Date(end); date.setDate(date.getDate() - days); return date.toISOString().slice(0, 10); }; return { opening, debit, credit, final, totals: { 应收: receivable, 其他应收: otherReceivable, 应付: payable, 其他应付: otherPayable }, rows: [ [dateBefore(2), "转出", "应收", "中信 · 5316", `${to} · 9481`, "往来款", "双边匹配", receivable], [dateBefore(9), "转出", "其他应收", "建行 · 0845", `${to} · 2046`, "资金调拨", "双边匹配", otherReceivable], [dateBefore(17), "转入", "应付", "中信 · 5316", `${to} · 9481`, "归还往来款", "双边匹配", payable], [dateBefore(25), "转入", "其他应付", "农行 · 3650", `${to} · 6120`, "临时往来", "单边待核", otherPayable], ], }; } const pairSubjectOrder = ["应收", "其他应收", "应付", "其他应付"]; function setPair(from, to, endDate = "2026-07-31") { $$('[data-pair-from]').forEach((item) => { item.textContent = from; }); $$('[data-pair-to]').forEach((item) => { item.textContent = to; }); $$("[data-pair-form]").forEach((form) => { const fromSelect = $('[name="from"]', form); const toSelect = $('[name="to"]', form); if (fromSelect && [...fromSelect.options].some((option) => option.value === from)) fromSelect.value = from; if (toSelect && [...toSelect.options].some((option) => option.value === to)) toSelect.value = to; const endInput = $('[name="end"]', form); if (endInput) endInput.value = endDate; }); if (!$("#pairReport")) return; const data = pairData(from, to, endDate); const format = (value) => value.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); $("#pairPeriod").textContent = `统计口径 2026.01.01—${endDate}`; $("#pairOpening").innerHTML = `${format(data.opening)}万元`; $("#pairDebit").innerHTML = `${format(data.debit)}万元`; $("#pairCredit").innerHTML = `${format(data.credit)}万元`; $("#pairFinal").innerHTML = `${data.final >= 0 ? "应收" : "应付"} ${format(Math.abs(data.final))}万元`; $("#pairReviewStatus").textContent = "含 1 笔待审核"; $('[data-subject-total="all"]').textContent = `${data.rows.length} 笔`; Object.entries(data.totals).forEach(([subject, value]) => { $(`[data-subject-total="${subject}"]`)?.replaceChildren(document.createTextNode(format(value))); }); state.pairContext = { from, to, endDate }; state.pairRows = data.rows.map(([date, direction, subject, ownAccount, counterparty, summary, match, amount]) => ({ date, direction, subject, ownAccount, counterparty, summary, match, amount })); renderPairRows(); } function renderPairRows() { const tbody = $("#pairTransactions"); if (!tbody) return; const format = (value) => Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); tbody.innerHTML = state.pairRows.map((row, index) => { const matched = row.match === "双边匹配"; return ` ${row.date} ${row.direction} ${row.subject} ${row.ownAccount} ${row.counterparty} ${row.summary} ${row.match} ${format(row.amount)} `; }).join(""); } function openTrace(index) { const modal = $("#traceModal"); if (!modal) return; const row = state.pairRows?.[index]; if (!row) return; const ctx = state.pairContext || { from: "—", to: "—" }; $("#traceSub").textContent = `${ctx.from} ↔ ${ctx.to} · ${row.subject} · ${row.date} · ${row.direction} ${formatCurrency(row.amount)} 万元`; $("#kvOwnTx").textContent = "—(演示数据,未关联银行流水)"; $("#kvOwnAcct").textContent = row.ownAccount; $("#kvPeerCo").textContent = ctx.to; $("#kvPeerAcct").textContent = row.counterparty; $("#kvTime").textContent = row.date; $("#kvAmt").textContent = `${formatCurrency(row.amount)} 万元`; $("#kvBatch").textContent = "—"; const evidence = $("#kvEvidence"); if (row.match === "双边匹配") { evidence.textContent = "—"; evidence.style.color = ""; } else { evidence.textContent = "待对方提供"; evidence.style.color = "var(--warn)"; } modal.classList.add("open"); } function initPairQueries() { $$("[data-pair-form]").forEach((form) => { $("[data-swap]", form)?.addEventListener("click", () => { const from = $('[name="from"]', form); const to = $('[name="to"]', form); const previous = from.value; from.value = to.value; to.value = previous; }); form.addEventListener("submit", (event) => { event.preventDefault(); const from = $('[name="from"]', form).value; const to = $('[name="to"]', form).value; const endDate = $('[name="end"]', form)?.value || "2026-07-31"; const error = $("#pairError"); if (from === to) { if (error) { error.style.display = ""; } showToast("请选择两个不同的公司", "同公司账户调拨不进入公司间往来查询", "warn"); return; } if (error) error.style.display = "none"; const notice = $("#pairNotice"); if (notice) notice.style.display = "none"; setPair(from, to, endDate); showView("pair"); }); }); $$("[data-pair-link]").forEach((button) => button.addEventListener("click", () => { const [from, to] = button.dataset.pairLink.split("|"); setPair(from, to); showView("pair"); })); $$("[data-subject-filter]").forEach((button) => button.addEventListener("click", () => { const subject = button.dataset.subjectFilter; $$("[data-subject-filter]").forEach((item) => { const active = item === button; item.classList.toggle("active", active); item.setAttribute("aria-pressed", String(active)); }); $$("#pairTransactions tr").forEach((row) => { row.hidden = subject !== "all" && row.dataset.subject !== subject; }); })); $("#pairTransactions")?.addEventListener("click", (event) => { const button = event.target.closest("[data-trace]"); if (button) openTrace(Number(button.dataset.trace)); }); $("#traceClose")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open")); $("#traceOk")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open")); $("#traceModal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); }); } function manualStatusMeta(status) { if (status === "已确认") return { cls: "pill-success", label: "已通过" }; if (status === "已驳回") return { cls: "pill-danger", label: "已驳回" }; return { cls: "pill-info", label: "待审核" }; } function renderCompanyManualRecords() { const tbody = $("#manualRecordRows"); if (!tbody) return; tbody.replaceChildren(); const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司"); [...records].reverse().forEach((record) => { const row = document.createElement("tr"); row.dataset.storedRecord = record.id; const date = document.createElement("td"); date.className = "num"; date.textContent = record.transactionDate || "—"; const direction = document.createElement("td"); direction.textContent = record.direction || "—"; const counterparty = document.createElement("td"); const counterpartyName = document.createElement("span"); counterpartyName.className = "cell-main"; counterpartyName.textContent = record.counterparty; const counterpartyType = document.createElement("span"); counterpartyType.className = "cell-sub"; counterpartyType.textContent = record.counterpartyType || ""; counterparty.append(counterpartyName, counterpartyType); const subject = document.createElement("td"); subject.innerHTML = `${record.subject || "—"}`; const isIn = record.direction === "收款"; const amount = document.createElement("td"); amount.className = `num-col ${isIn ? "amt-in" : "amt-out"}`; amount.textContent = `${isIn ? "+" : "-"}¥ ${formatCurrency(record.amount)}`; const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = record.summary || "—"; const statusMeta = manualStatusMeta(record.status); const statusCell = document.createElement("td"); statusCell.innerHTML = `${statusMeta.label}`; const action = document.createElement("td"); if (record.status === "待总账复核") { action.innerHTML = ''; } else { action.innerHTML = ''; } row.append(date, direction, counterparty, subject, amount, summary, statusCell, action); tbody.append(row); }); updateManualCounts(); } function updateManualCounts() { const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司"); const pending = records.filter((record) => record.status === "待总账复核").length; const pendingEl = $("#manualPendingStatus"); if (pendingEl) pendingEl.textContent = pending; const foot = $("#manualFoot"); if (foot) foot.textContent = `共 ${records.length} 条 · 待复核 ${pending} 条`; const empty = $("#manualEmpty"); if (empty) empty.hidden = records.length > 0; } function fillAccountSelects(accounts) { const usable = accounts.filter((account) => account.usable); [$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => { if (!select) return; const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账"); select.replaceChildren(...kept); usable.forEach((account) => { const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`; const option = new Option(value, value); option.dataset.accountId = account.id; select.add(option); }); }); } function companyAccountMeta(status) { if (status === "active") return { status: { cls: "pill-success", label: "启用" }, audit: { cls: "pill-success", label: "已审核" } }; if (status === "returned") return { status: { cls: "pill-danger", label: "已退回" }, audit: { cls: "pill-danger", label: "已退回" } }; if (status === "disabled") return { status: { cls: "pill-muted", label: "停用" }, audit: { cls: "pill-success", label: "已审核" } }; return { status: { cls: "pill-info", label: "待启用" }, audit: { cls: "pill-info", label: "待审核" } }; } function renderCompanyAccounts(accounts) { state.accounts = accounts; const tbody = $("#account-tbody"); if (tbody) { tbody.replaceChildren(...accounts.map((account) => { const row = document.createElement("tr"); row.dataset.accountId = account.id; const identity = document.createElement("td"); const name = document.createElement("span"); name.className = "cell-main"; name.textContent = account.bank_name; const tail = document.createElement("span"); tail.className = "cell-sub"; tail.textContent = `尾号 ${accountTail(account.account_number_masked)}`; identity.append(name, tail); const type = document.createElement("td"); type.innerHTML = `${account.account_type || "—"}`; const meta = companyAccountMeta(account.status); const statusCell = document.createElement("td"); statusCell.innerHTML = `${meta.status.label}`; const coverage = document.createElement("td"); coverage.className = "num muted"; coverage.textContent = account.usable ? "尚未上传" : "—"; const auditCell = document.createElement("td"); auditCell.innerHTML = `${meta.audit.label}`; const action = document.createElement("td"); action.innerHTML = ''; row.append(identity, type, statusCell, coverage, auditCell, action); return row; })); } const count = $("#account-count"); if (count) count.textContent = accounts.length; const foot = $("#account-foot"); if (foot) foot.textContent = `共 ${accounts.length} 个账户`; const empty = $("#account-empty"); if (empty) empty.hidden = accounts.length > 0; fillAccountSelects(accounts); } async function loadCompanyAccounts() { const tbody = $("#account-tbody"); showTableLoading(tbody, 6); const response = await fetch("/api/company/accounts").catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } if (!response?.ok) { showTableError(tbody, 6); return; } const result = await response.json().catch(() => null); if (result?.accounts) renderCompanyAccounts(result.accounts); else showTableError(tbody, 6); } function appendAdminReviewRow(record, kind) { const tbody = $("#auditRows"); if (!tbody) return; const isAccount = kind === "account"; const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status; const row = document.createElement("tr"); row.dataset.storedReview = record.id; row.dataset.recordId = record.id; row.dataset.recordKind = kind; if (isAccount) row.dataset.accountId = record.id; row.dataset.auditType = isAccount ? "账户" : "手工"; row.dataset.company = isAccount ? record.company_name : record.company; row.dataset.evidence = isAccount ? "公司提交资料、开户行、账号、账户类型与启用日期" : "公司手工记录、关联银行流水号、证明附件与提交说明"; if (statusLabel !== "待复核" && statusLabel !== "待总账复核") row.dataset.resolved = "true"; if (isAccount) row.dataset.accountStatus = record.status; const riskCell = document.createElement("td"); riskCell.innerHTML = ''; const identityCell = document.createElement("td"); const identity = document.createElement("span"); identity.className = "cell-main"; const detail = document.createElement("span"); detail.className = "cell-sub"; if (isAccount) { identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`; detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`; } else { identity.textContent = `${record.company} · ${record.id}`; detail.textContent = `${record.direction} ${formatCurrency(record.amount)} 元 · ${record.counterparty} · ${record.subject}`; } identityCell.append(identity, detail); const typeCell = document.createElement("td"); typeCell.textContent = isAccount ? "账户登记" : "手工记录"; const periodCell = document.createElement("td"); periodCell.textContent = isAccount ? (record.effective_from || "待审核确定") : record.transactionDate; const impactCell = document.createElement("td"); impactCell.className = "wrap"; impactCell.textContent = isAccount ? "账户识别与流水上传" : `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`; const statusCell = document.createElement("td"); const status = recordStatus(statusLabel); statusCell.innerHTML = `${status.label}`; const actionCell = document.createElement("td"); if (row.dataset.resolved) { const isActiveAccount = isAccount && record.status === "active"; if (isActiveAccount) { actionCell.innerHTML = ''; } else { const resolvedLabel = isAccount ? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过" : (statusLabel === "已确认" ? "已通过" : "已驳回"); actionCell.innerHTML = `${resolvedLabel} · 系统管理员`; } } else { actionCell.innerHTML = '
'; } row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell); tbody.append(row); } async function renderAdminAccountReviews() { const tbody = $("#auditRows"); if (!tbody) return; $$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove()); const response = await fetch("/api/admin/accounts").catch(() => null); if (!response?.ok) { showToast("审核数据加载失败", "请稍后重试", "danger"); return; } const result = await response.json().catch(() => null); (result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account")); updateAuditCounts(); updatePendingAccountNotice(); } function renderStoredAdminReviews() { if (!$("#auditRows")) return; $$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove()); readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual")); renderAdminAccountReviews(); } function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) { if (kind !== "manual" || !id) return; const records = readStoredRecords(storageKeys.manual); const record = records.find((item) => item.id === id); if (!record) return; Object.assign(record, { status, decision, reviewReason, reviewedAt }); writeStoredRecords(storageKeys.manual, records); } function updateAuditCounts() { const rows = $$("#auditRows tr"); const unresolved = rows.filter((row) => row.dataset.resolved !== "true"); $$('[data-audit-filter]').forEach((button) => { const type = button.dataset.auditFilter; const count = unresolved.filter((row) => type === "all" || row.dataset.auditType === type).length; const badge = $(".tab-count", button); if (badge) badge.textContent = count; }); const badge = $('.side-nav a[data-view="audit"] .nav-badge'); if (badge) { badge.textContent = unresolved.length; badge.style.display = unresolved.length ? "" : "none"; } const pending = $("#pending-count"); if (pending) pending.textContent = unresolved.length; const foot = $("#auditFoot"); if (foot) foot.textContent = `共 ${rows.length} 项 · 待审核 ${unresolved.length} 项`; } function companyStatusBadge(status) { if (status === "preparing") return { className: "neutral", label: "筹备中" }; if (status === "disabled") return { className: "danger", label: "已停用" }; return { className: "success", label: "正常" }; } function renderAdminCompanyTable(companies) { const tbody = $("#companyTable tbody"); if (!tbody) return; state.companies = companies; tbody.replaceChildren(...companies.map((company) => { const row = document.createElement("tr"); row.dataset.companyId = company.id; const nameCell = document.createElement("td"); const name = document.createElement("span"); name.className = "cell-main"; name.textContent = company.name; const code = document.createElement("span"); code.className = "cell-sub"; code.textContent = company.credit_code || "统一社会信用代码待补充"; nameCell.append(name, code); const accounts = document.createElement("td"); accounts.className = "num-col"; accounts.textContent = `${company.account_count ?? 0}`; const usernames = document.createElement("td"); usernames.className = "num"; usernames.textContent = company.usernames || "未创建"; const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定"; const statusCell = document.createElement("td"); const badge = companyStatusBadge(company.status); statusCell.innerHTML = `${badge.label}`; const actionCell = document.createElement("td"); actionCell.innerHTML = ``; row.append(nameCell, accounts, usernames, cashier, statusCell, actionCell); return row; })); const foot = $("#companyFoot"); if (foot) foot.textContent = `共 ${companies.length} 家公司`; } function updatePendingAccountNotice() { const rows = $$('#auditRows tr[data-record-kind="account"]'); const pending = rows.filter((r) => r.dataset.resolved !== "true"); const notice = $("#notice-pending-account"); if (!notice) return; if (pending.length) { notice.style.display = ""; $("#pending-account-count").textContent = pending.length; const names = [...new Set(pending.map((r) => r.dataset.company))].join("、"); $("#pending-account-body").textContent = (names || "成员公司") + " 提交了新银行账户登记,等待审核通过后纳入账期流水归集范围。"; } else { notice.style.display = "none"; } } function setSelectOptions(select, names, { keepFirst = false } = {}) { if (!select || !names.length) return; const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : []; select.replaceChildren(...kept, ...names.map((name) => new Option(name, name))); } function fillCompanySelects(names) { // Every company picker is driven by master data: a newly created company // appears in pair queries, audit filters, flow filters and reminders // without any code change. $$("[data-pair-form]").forEach((form) => { setSelectOptions($('[name="from"]', form), names); setSelectOptions($('[name="to"]', form), names); const toSelect = $('[name="to"]', form); if (toSelect && names.length > 1) toSelect.value = names[1]; }); setSelectOptions($("#auditCompany"), names, { keepFirst: true }); setSelectOptions($("#flowCompany"), names, { keepFirst: true }); const reminderList = $("#reminderCompanyList"); if (reminderList) { reminderList.replaceChildren(...names.map((name) => { const label = document.createElement("label"); label.className = "row"; label.style.cssText = "gap:6px;font-size:13px;"; const input = document.createElement("input"); input.type = "checkbox"; input.name = "company"; input.value = name; input.style.width = "auto"; label.append(input, document.createTextNode(name)); return label; })); } setSelectOptions($('#openingDialog [name="from"]'), names); setSelectOptions($('#openingDialog [name="to"]'), names); } async function loadAdminCompanies() { const tbody = $("#companyTable tbody"); showTableLoading(tbody, 6); const response = await fetch("/api/admin/companies").catch(() => null); if (!response?.ok) { showTableError(tbody, 6); return; } const result = await response.json().catch(() => null); const companies = result?.companies || []; renderAdminCompanyTable(companies); fillCompanySelects(companies.map((company) => company.name)); if (companies.length >= 2) setPair(companies[0].name, companies[1].name); } function initAdmin() { renderStoredAdminReviews(); updateAuditCounts(); loadAdminCompanies(); let activeAuditType = "all"; function filterAuditRows() { const company = $("#auditCompany")?.value || "全部公司"; $$(".audit-table tbody tr").forEach((row) => { const typeMatches = activeAuditType === "all" || row.dataset.auditType === activeAuditType; const companyMatches = company === "全部公司" || row.dataset.company === company; row.hidden = !(typeMatches && companyMatches); }); } $$("[data-audit-filter]").forEach((button) => button.addEventListener("click", () => { activeAuditType = button.dataset.auditFilter; $$("[data-audit-filter]").forEach((item) => { const active = item === button; item.classList.toggle("active", active); item.setAttribute("aria-pressed", String(active)); }); filterAuditRows(); })); $("#auditCompany")?.addEventListener("change", filterAuditRows); function openModal(id) { $("#" + id)?.classList.add("open"); } function closeModal(id) { $("#" + id)?.classList.remove("open"); } document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close))); document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); })); document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); }); async function submitAuditResult(row) { const decision = state.auditDecision; const reason = state.auditReason || ""; const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用"); const returned = decision.includes("退回"); let storedStatus; let reviewedAccount = null; if (row.dataset.recordKind === "account" && row.dataset.accountId) { // Server-side review: the account only becomes usable after this succeeds. const apiDecision = approved ? "approve" : returned ? "return" : "disable"; const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision: apiDecision, reason }), }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("审核结果提交失败", result?.message || "请稍后重试", "danger"); return; } reviewedAccount = result.account; storedStatus = accountStatusLabel(result.account?.status); } else { storedStatus = approved ? (row.dataset.recordKind === "account" ? "已启用" : "已确认") : (returned ? "已退回" : "异常待处理"); updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, reason, new Date().toLocaleString("zh-CN", { hour12: false })); } const status = recordStatus(storedStatus); const statusCell = row.children[5]; statusCell.innerHTML = `${status.label}`; row.dataset.resolved = "true"; if (reviewedAccount) row.dataset.accountStatus = reviewedAccount.status; const actionCell = row.children[6]; if (reviewedAccount && reviewedAccount.status === "active") { actionCell.innerHTML = ''; } else { actionCell.innerHTML = `${approved ? "已通过" : "已驳回"} · 系统管理员`; } updateAuditCounts(); showToast("审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算", "success"); } $("#auditRows")?.addEventListener("click", (event) => { const button = event.target.closest("[data-audit-action]"); if (!button) return; const row = button.closest("tr"); state.auditRow = row; const cells = $$("td", row); const company = cells[1]?.querySelector(".cell-main")?.textContent.trim() || ""; const period = cells[3]?.textContent.trim() || ""; const type = cells[2]?.textContent.trim() || ""; const basis = row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"; const action = button.dataset.auditAction; if (action === "approve") { $("#approve-company").textContent = company; $("#approve-period").textContent = period; $("#approve-type").textContent = type; $("#approve-basis").textContent = basis; openModal("modal-approve"); } else if (action === "reject") { $("#reject-reason").value = ""; $("#reject-hint").style.display = "none"; openModal("modal-reject"); } else if (action === "disable") { state.auditDecision = "停用并驳回"; state.auditReason = "停用并驳回该账户"; submitAuditResult(row); } }); $("#approve-confirm")?.addEventListener("click", () => { if (!state.auditRow) return; closeModal("modal-approve"); state.auditDecision = state.auditRow.dataset.recordKind === "account" ? "复核通过并启用账户" : "确认并纳入计算"; state.auditReason = state.auditDecision; submitAuditResult(state.auditRow); }); $("#reject-confirm")?.addEventListener("click", () => { if (!state.auditRow) return; const reason = $("#reject-reason").value.trim(); if (reason.length < 5) { $("#reject-hint").style.display = ""; return; } closeModal("modal-reject"); state.auditDecision = state.auditRow.dataset.recordKind === "account" ? "退回公司修改" : "退回公司补充材料"; state.auditReason = reason; submitAuditResult(state.auditRow); }); $("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog")); $("#companyForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); const companyName = String(data.get("companyName") || "").trim(); const loginName = String(data.get("loginName") || "").trim(); const createUser = data.get("createUser") !== null; const payload = { name: companyName, credit_code: String(data.get("creditCode") || "").trim(), cashier_name: String(data.get("cashier") || "").trim(), }; if (createUser && loginName) payload.username = loginName; const response = await fetch("/api/admin/companies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("公司创建失败", result?.message || "请稍后重试", "danger"); return; } const accountCreated = Boolean(result.username); closeModal("companyDialog"); event.currentTarget.reset(); await loadAdminCompanies(); showToast( accountCreated ? "公司与账号已创建" : "公司已创建", accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号", "success", ); }); function openCompanyDetail(companyId) { const company = (state.companies || []).find((c) => String(c.id) === String(companyId)); if (!company) return; $("#d-title").textContent = company.name; $("#d-sub").textContent = `统一社会信用代码 ${company.credit_code || "待补充"}`; $("#d-kv").innerHTML = '
公司名称
' + company.name + '
' + '
统一社会信用代码
' + (company.credit_code || "待补充") + '
' + '
出纳
' + (company.cashier_name || "未指定") + '
' + '
银行账户
' + (company.account_count ?? 0) + ' 个
' + '
状态
' + companyStatusBadge(company.status).label + '
'; $("#d-login-account").textContent = company.usernames || "未创建公司账号"; $("#d-login-meta").textContent = company.usernames ? "公司端登录账号" : "该公司尚未创建登录账号"; const resetBtn = $("#btn-reset-pwd"); resetBtn.disabled = !company.usernames; resetBtn.textContent = "重置密码"; resetBtn.dataset.companyId = company.id; openModal("modal-detail"); } $("#companyTable tbody")?.addEventListener("click", (event) => { const btn = event.target.closest("[data-company-view]"); if (btn) openCompanyDetail(btn.dataset.companyView); }); $("#btn-reset-pwd")?.addEventListener("click", async () => { const companyId = $("#btn-reset-pwd").dataset.companyId; const usersResponse = await fetch("/api/admin/users").catch(() => null); const usersResult = await usersResponse?.json().catch(() => ({})); const user = (usersResult?.users || []).find((u) => String(u.company_id) === String(companyId) && u.role === "company"); if (!user) { showToast("重置失败", "该公司尚无公司登录账号", "danger"); return; } const response = await fetch(`/api/admin/users/${user.id}/reset-password`, { method: "POST" }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; } $("#btn-reset-pwd").disabled = true; $("#btn-reset-pwd").textContent = "已重置"; $("#d-login-meta").textContent = `临时密码已生成(仅此一次):${result.initial_password},首次登录必须修改`; showToast("密码已重置", "临时密码仅本次显示,首次登录必须修改", "success"); }); const remind = $("#cs-remind"); const track = $("#cs-switch-track"); const thumb = $("#cs-switch-thumb"); const daysSel = $("#cs-remind-days"); function renderSwitch() { if (!remind) return; track.style.background = remind.checked ? "var(--accent)" : "var(--fg-soft)"; thumb.style.left = remind.checked ? "18px" : "2px"; if (daysSel) daysSel.disabled = !remind.checked; } remind?.addEventListener("change", renderSwitch); renderSwitch(); $("#systemSettings")?.addEventListener("submit", (event) => { event.preventDefault(); const day = parseInt($("#cs-day")?.value, 10); const tip = $("#cs-save-tip"); if (day && (day < 1 || day > 28)) { if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; } return; } if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; } showToast("系统计算口径已保存", "正式系统将记录修改前后值与操作人", "success"); }); $("#runClosingCheck")?.addEventListener("click", () => { const unresolved = $$(".audit-table tbody tr").filter((row) => row.dataset.resolved !== "true").length; const blockNotice = $("#block-notice"); if (unresolved) { if (blockNotice) blockNotice.style.display = ""; $("#closingDescription").textContent = `2026 年 7 月 · 仍有 ${unresolved} 项审核事项未处理`; $("#closingStatus").className = "pill pill-danger"; $("#closingStatus").textContent = "已阻断"; $("#executeClosing").disabled = true; showToast("结账检查未通过", `仍有 ${unresolved} 项审核事项,已打开审核中心`, "warn"); showView("audit"); } else { if (blockNotice) blockNotice.style.display = "none"; $$("#closingPanel [data-closing-state]").forEach((item) => { item.className = "pill pill-success"; item.textContent = "已通过"; }); $("#closingDescription").textContent = "2026 年 7 月 · 全部前置检查已通过"; $("#closingStatus").className = "pill pill-success"; $("#closingStatus").textContent = "可结账"; $("#executeClosing").disabled = false; $("#executeClosing").removeAttribute("title"); showToast("结账检查通过", "执行结账按钮已解锁", "success"); } }); $("#executeClosing")?.addEventListener("click", () => openModal("closingDialog")); $$("[data-close-closing]").forEach((button) => button.addEventListener("click", () => closeModal("closingDialog"))); $("#closingForm")?.addEventListener("submit", (event) => { event.preventDefault(); closeModal("closingDialog"); $("#closingDescription").textContent = "2026 年 7 月 · 已完成集团结账"; $("#closingStatus").className = "pill pill-success"; $("#closingStatus").textContent = "已结账"; $("#closingHistory").textContent = `${new Date().toLocaleString("zh-CN", { hour12: false })} · 系统管理员执行 2026 年 7 月结账 · 已写入审计记录`; $("#runClosingCheck").disabled = true; $("#executeClosing").disabled = true; $("#executeClosing").textContent = "7 月已结账"; const closedNotice = $("#closed-notice"); if (closedNotice) closedNotice.style.display = ""; const tlCurrent = $("#tl-current"); tlCurrent?.classList.remove("current"); tlCurrent?.classList.add("closed"); $("#tl-current-state").textContent = "已结账"; showToast("2026 年 7 月已完成结账", "本期结果已锁定,后续补录将进入重开流程", "success"); }); $("#openOpeningDialog")?.addEventListener("click", () => openModal("openingDialog")); $$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog"))); $("#openingForm")?.addEventListener("submit", (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); if (data.get("from") === data.get("to")) { showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初", "warn"); return; } const row = document.createElement("tr"); const self = document.createElement("td"); self.className = "cell-main"; self.textContent = data.get("from"); row.append(self); const peer = document.createElement("td"); peer.textContent = data.get("to"); row.append(peer); const subject = document.createElement("td"); subject.textContent = data.get("subject"); row.append(subject); const direction = document.createElement("td"); direction.textContent = data.get("direction"); row.append(direction); const amount = document.createElement("td"); amount.className = "num-col"; amount.textContent = Number(data.get("amount")).toLocaleString("zh-CN", { minimumFractionDigits: 2 }); row.append(amount); const date = document.createElement("td"); date.className = "num"; date.textContent = data.get("effectiveDate"); row.append(date); const status = document.createElement("td"); status.innerHTML = '待复核'; row.append(status); $("#openingRows").append(row); closeModal("openingDialog"); event.currentTarget.reset(); showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人", "success"); }); $("#reminderForm")?.addEventListener("submit", (event) => { event.preventDefault(); const form = event.currentTarget; const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked')); const type = $("#reminder-type").value; const content = $("#reminder-content").value.trim(); const deadline = $("#reminder-deadline").value; $("#company-error").style.display = checked.length ? "none" : ""; $("#content-error").style.display = content ? "none" : ""; if (!checked.length || !content) return; const companies = checked.map((c) => c.value).join("、"); const summary = content.length > 46 ? content.slice(0, 46) + "…" : content; const row = document.createElement("tr"); row.dataset.source = "manual"; row.innerHTML = '' + companies + '' + '' + type + '' + '' + summary.replace(/' + '刚刚人工 · 系统管理员' + '' + (deadline || "—") + '' + '未读' + '
'; $("#reminder-tbody").insertBefore(row, $("#reminder-tbody").firstChild); refreshReminderCounts(); form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; }); $("#reminder-content").value = "请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。"; $("#reminder-deadline").value = "2026-08-29"; const sendHint = $("#send-hint"); sendHint.style.display = ""; sendHint.textContent = "已发送给 " + companies + ",共 " + checked.length + " 家公司。"; clearTimeout(sendHint._t); sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000); }); // 提醒历史 tabs / 计数 function refreshReminderCounts() { const rows = $$("#reminder-tbody tr"); let all = rows.length, sys = 0, man = 0; rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; }); $("#count-all").textContent = all; $("#count-system").textContent = sys; $("#count-manual").textContent = man; $("#table-foot-count").textContent = "共 " + all + " 条提醒记录"; } $("#reminder-tabs")?.addEventListener("click", (event) => { const btn = event.target.closest("button[data-filter]"); if (!btn) return; $("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); const filter = btn.dataset.filter; $$("#reminder-tbody tr").forEach((r) => { r.style.display = (filter === "all" || r.dataset.source === filter) ? "" : "none"; }); }); $("#reminder-tbody")?.addEventListener("click", (event) => { const remindBtn = event.target.closest(".act-remind"); if (remindBtn) { const tr = remindBtn.closest("tr"); const timeCell = tr.children[3]; const sub = timeCell.querySelector(".cell-sub"); timeCell.firstChild.textContent = "刚刚"; if (sub) sub.textContent = sub.textContent.replace(/\s*·?\s*第 \d+ 次/, "") + " · 再提醒"; else timeCell.insertAdjacentHTML("beforeend", '再提醒'); remindBtn.textContent = "已再提醒"; remindBtn.disabled = true; return; } const historyBtn = event.target.closest(".act-history"); if (historyBtn) openReminderHistory(historyBtn.dataset.company); }); const reminderHISTORIES = { "金牛农业": [ { time: "2026-08-18 09:02", src: "系统 · 第 2 次", type: "流水未提交", status: "danger", statusText: "未读", body: "贵公司 7 月全部银行账户流水尚未提交,距 7 月结账顺延截止日仅剩 11 天,请尽快上传。" }, { time: "2026-08-11 09:00", src: "系统 · 第 1 次", type: "流水未提交", status: "danger", statusText: "未读", body: "2026 年 7 月银行流水上传提醒:请于截止日前完成全部账户流水提交。" }, ], "金牛新能源": [ { time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "warn", statusText: "处理中", body: "与金牛贸易 3 笔单边流水合计 ¥4,860,000.00 待确认,请选择对方银行流水佐证。" }, ], "金牛置业": [ { time: "2026-08-14 16:40", src: "人工 · 张维", type: "流水未提交", status: "warn", statusText: "处理中", body: "中行尾号 8821 账户 7 月 6—16 日流水断档,请补传该期间银行回单或对账单。" }, ], "金牛贸易": [ { time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "success", statusText: "已完成", body: "与金牛新能源往来 3 笔单边流水待确认,请核对 7 月销售回款记录。" }, ], "金牛煤业": [ { time: "2026-08-12 09:00", src: "系统", type: "科目待确认", status: "warn", statusText: "处理中", body: "2 笔手工记录往来科目待确认(应收 / 其他应收),涉及煤炭运费结算。" }, ], "金牛物流": [ { time: "2026-08-10 11:25", src: "人工 · 张维", type: "科目待确认", status: "success", statusText: "已完成", body: "1 笔运费结算手工记录科目待确认(其他应付待判定),请于结账前完成。" }, ], }; function openReminderHistory(company) { $("#history-title").textContent = company + " · 提醒历史"; $("#history-sub").textContent = "2026 年 7 月账期以来的全部触达记录,按时间倒序。"; const list = $("#history-list"); const items = reminderHISTORIES[company] || []; if (!items.length) { list.innerHTML = '
暂无历史记录
该公司在 7 月账期内尚未收到过提醒。
'; } else { list.innerHTML = items.map((it) => { return '
' + '' + it.type + '' + '
' + it.body + '
' + it.time + ' · ' + it.src + '
' + '
' + it.statusText + '
'; }).join(""); } openModal("history-modal"); } $("#history-close")?.addEventListener("click", () => closeModal("history-modal")); $("#history-ok")?.addEventListener("click", () => closeModal("history-modal")); refreshReminderCounts(); } const FLOW_DEMO = [ { date: "2026-07-01", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "平顶山市恒源电力燃料有限公司", summary: "煤炭销售款(6 月结算)", serial: "ICBC202607010031825", status: "未归集", statusPill: "pill-muted", amount: "1,860,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-01 09:42:17", peer: "平顶山市恒源电力燃料有限公司", peerAcct: "工行平顶山分行 1702 0218 0902 6641 20", amount: "¥ 1,860,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对方为集团外客户,不进入内部往来归集,仅作银行流水留档。" } }, { date: "2026-07-03", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "光伏支架材料款", serial: "ICBC202607030094417", status: "单边", statusPill: "pill-danger", amount: "1,620,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-03 14:08:52", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,620,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-002", note: "贸易侧 7 月上报流水中未找到对应收款,已挂起为单边流水,待贸易侧补充银行凭证佐证。" } }, { date: "2026-07-03", company: "金牛置业", bank: "中行", account: "8821", acctLabel: "中行 · 尾号 8821", dir: "收", dirPill: "pill-success", peer: "郑州市商品房预售资金监管专户", summary: "商品房预售款(A 区 12 号楼)", serial: "BOC202607030552108", status: "未归集", statusPill: "pill-muted", amount: "4,150,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛置业有限公司 · 一般户 2546 0387 0200 8821", time: "2026-07-03 10:26:31", peer: "郑州市商品房预售资金监管专户", peerAcct: "中行郑州郑东新区支行 2546 1180 0200 3477", amount: "¥ 4,150,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "预售监管资金划入,属对外经营收款,不参与集团内部往来归集。" } }, { date: "2026-07-05", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "河南金牛物流有限公司", summary: "矿区运输费(6 月)", serial: "ICBC202607050127663", status: "已归集", statusPill: "pill-success", amount: "1,240,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-05 11:15:09", peer: "河南金牛物流有限公司", peerAcct: "建行郑州经开区支行 4105 0167 8080 5562", amount: "¥ 1,240,000.00(付)", status: "已归集", pair: "金牛煤业 ↔ 金牛物流", subject: "应付(煤业侧)", batch: "JC-2026-07-014", note: "与物流侧建行尾号 5562 账户 07-05 收款流水双向匹配,金额一致。" } }, { date: "2026-07-05", company: "金牛物流", bank: "建行", account: "5562", acctLabel: "建行 · 尾号 5562", dir: "收", dirPill: "pill-success", peer: "河南金牛煤业有限公司", summary: "矿区运输费(6 月)", serial: "CCB202607050312940", status: "已归集", statusPill: "pill-success", amount: "1,240,000.00", amtClass: "amt-in", detail: { bank: "建设银行", account: "河南金牛物流有限公司 · 基本户 4105 0167 8080 5562", time: "2026-07-05 11:15:36", peer: "河南金牛煤业有限公司", peerAcct: "工行平顶山分行 1702 0231 0900 8133 05", amount: "¥ 1,240,000.00(收)", status: "已归集", pair: "金牛物流 ↔ 金牛煤业", subject: "应收(物流侧)", batch: "JC-2026-07-014", note: "与煤业侧工行尾号 3305 账户 07-05 付款流水双向匹配,金额一致。" } }, { date: "2026-07-08", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "煤炭采购款(7 月)", serial: "ICBC202607080208554", status: "待确认", statusPill: "pill-warn", amount: "3,200,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-08 15:47:22", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 3,200,000.00(收)", status: "待确认", pair: "金牛煤业 ↔ 金牛置业", subject: "应收(煤业侧)", batch: "—(待归集)", note: "置业中行尾号 8821 账户 07-06 至 07-16 流水断档,对方付款凭证缺失,暂无法完成双边匹配,已列入审核中心高风险事项。" } }, { date: "2026-07-09", company: "金牛贸易", bank: "农行", account: "2208", acctLabel: "农行 · 尾号 2208", dir: "收", dirPill: "pill-success", peer: "洛阳建工集团有限公司", summary: "钢材销售款(6 月发货)", serial: "ABC202607090773261", status: "未归集", statusPill: "pill-muted", amount: "2,480,000.00", amtClass: "amt-in", detail: { bank: "农业银行", account: "河南金牛贸易有限公司 · 基本户 1606 3301 0400 0220 8", time: "2026-07-09 09:58:44", peer: "洛阳建工集团有限公司", peerAcct: "中行洛阳分行 2546 2201 0500 7915", amount: "¥ 2,480,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外钢材销售回款,不参与集团内部往来归集。" } }, { date: "2026-07-11", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "电缆及配电柜采购款", serial: "ICBC202607110158902", status: "单边", statusPill: "pill-danger", amount: "1,950,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-11 16:32:08", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,950,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-005", note: "贸易侧无对应收款记录,单边挂起。新能源↔贸易本月累计 3 笔单边流水,合计 486 万元。" } }, { date: "2026-07-14", company: "金牛煤业", bank: "中行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "平顶山天安煤业设备租赁有限公司", summary: "综采设备租赁费(7 月)", serial: "BOC202607140416337", status: "未归集", statusPill: "pill-muted", amount: "920,000.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-14 10:11:57", peer: "平顶山天安煤业设备租赁有限公司", peerAcct: "建行平顶山分行 4105 0229 8080 1347", amount: "¥ 920,000.00(付)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "综采设备月度租赁支出,对方为集团外供应商,不参与内部归集。" } }, { date: "2026-07-18", company: "金牛煤业", bank: "交行", account: "7710", acctLabel: "交行 · 尾号 7710", dir: "付", dirPill: "pill-danger", peer: "平顶山市安泰矿山设备有限公司", summary: "提升机大修款", serial: "BOCOM202607180062194", status: "待确认", statusPill: "pill-warn", amount: "685,400.00", amtClass: "amt-out", detail: { bank: "交通银行", account: "河南金牛煤业有限公司 · 一般户 4110 6120 0181 0077 10(账户待审核)", time: "2026-07-18 13:29:40", peer: "平顶山市安泰矿山设备有限公司", peerAcct: "工行平顶山分行 1702 0218 0902 9075 63", amount: "¥ 685,400.00(付)", status: "待确认", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "付款账户(交行尾号 7710)为新开户,尚在账户审核流程中,流水暂挂待确认,审核通过后自动归档为外部交易。" } }, { date: "2026-07-21", company: "金牛置业", bank: "中行", account: "8821", acctLabel: "中行 · 尾号 8821", dir: "收", dirPill: "pill-success", peer: "郑州市商品房预售资金监管专户", summary: "商品房预售款(A 区 15 号楼)", serial: "BOC202607210588420", status: "未归集", statusPill: "pill-muted", amount: "3,780,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛置业有限公司 · 一般户 2546 0387 0200 8821", time: "2026-07-21 09:35:12", peer: "郑州市商品房预售资金监管专户", peerAcct: "中行郑州郑东新区支行 2546 1180 0200 3477", amount: "¥ 3,780,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "预售监管资金划入。该账户 07-06 至 07-16 存在流水断档,本笔为断档后首笔入账。" } }, { date: "2026-07-22", company: "金牛物流", bank: "建行", account: "5562", acctLabel: "建行 · 尾号 5562", dir: "收", dirPill: "pill-success", peer: "河南金牛贸易有限公司", summary: "钢材干线运输费(6-7 月)", serial: "CCB202607220347815", status: "已归集", statusPill: "pill-success", amount: "462,800.00", amtClass: "amt-in", detail: { bank: "建设银行", account: "河南金牛物流有限公司 · 基本户 4105 0167 8080 5562", time: "2026-07-22 14:52:26", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 462,800.00(收)", status: "已归集", pair: "金牛物流 ↔ 金牛贸易", subject: "应收(物流侧)", batch: "JC-2026-07-021", note: "与贸易侧农行尾号 2208 账户 07-22 付款流水双向匹配,金额一致,已计入 7 月往来批次。" } }, { date: "2026-07-24", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "组件辅材结算款", serial: "ICBC202607240221476", status: "单边", statusPill: "pill-danger", amount: "1,290,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-24 11:06:33", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,290,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-009", note: "贸易侧无对应收款记录,单边挂起。新能源↔贸易本月累计 3 笔单边流水,合计 486 万元。" } }, { date: "2026-07-25", company: "金牛贸易", bank: "农行", account: "2208", acctLabel: "农行 · 尾号 2208", dir: "付", dirPill: "pill-danger", peer: "安阳钢铁集团有限责任公司", summary: "螺纹钢采购款(7 月)", serial: "ABC202607250819673", status: "未归集", statusPill: "pill-muted", amount: "5,620,000.00", amtClass: "amt-out", detail: { bank: "农业银行", account: "河南金牛贸易有限公司 · 基本户 1606 3301 0400 0220 8", time: "2026-07-25 10:19:05", peer: "安阳钢铁集团有限责任公司", peerAcct: "工行安阳分行 1706 0211 0900 4428 17", amount: "¥ 5,620,000.00(付)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外螺纹钢采购付款,不参与集团内部往来归集。" } }, ]; // 原型占位,非本司真流水:COMPANY_FLOWS 仅用于演示公司端流水列表界面, // 真实流水来自 /api/parse 导入与 /api/batches 批次,切勿把它当成真数据源。 const COMPANY_FLOWS = [ { date: "2026-07-02", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "煤炭采购款(2026 年 6 月供煤合同结算)", serial: "ICBC2026070200185347", status: "已归集", statusPill: "pill-success", amount: "3,200,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-02 15:47:22", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 3,200,000.00(收)", status: "已归集", pair: "金牛煤业 ↔ 金牛置业", subject: "应收(煤业侧)", batch: "JH-202607-014", note: "与置业侧中行尾号 8821 账户付款流水已双向匹配。" } }, { date: "2026-07-03", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "山西晋城王坡煤矿有限责任公司", summary: "原料煤采购预付款", serial: "ICBC2026070300221091", status: "未归集 · 外部", statusPill: "pill-muted", amount: "860,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-03 10:26:31", peer: "山西晋城王坡煤矿有限责任公司", peerAcct: "工行晋城分行 1702 0218 0902 6641 20", amount: "¥ 860,000.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对方为集团外供应商,不参与内部往来归集,仅作银行流水留档。" } }, { date: "2026-07-06", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "河南神火运销有限公司", summary: "动力煤销售货款(7 月第一批)", serial: "BOC2026070600772018", status: "未归集 · 外部", statusPill: "pill-muted", amount: "1,246,800.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-06 09:58:44", peer: "河南神火运销有限公司", peerAcct: "工行永城分行 1702 0218 0902 9075 63", amount: "¥ 1,246,800.00(收)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外动力煤销售回款,不参与集团内部往来归集。" } }, { date: "2026-07-08", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "河南金牛物流有限公司", summary: "6 月煤炭公路运输费结算", serial: "ICBC2026070800311276", status: "已归集", statusPill: "pill-success", amount: "486,500.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-08 11:15:09", peer: "河南金牛物流有限公司", peerAcct: "建行郑州经开区支行 4105 0167 8080 5562", amount: "¥ 486,500.00(付)", status: "已归集", pair: "金牛煤业 ↔ 金牛物流", subject: "应付(煤业侧)", batch: "JH-202607-014", note: "与物流侧建行收款流水已双向匹配,运单 42 张随附。" } }, { date: "2026-07-10", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "选煤设备配件代购款", serial: "BOC2026071000819455", status: "待确认", statusPill: "pill-warn", amount: "214,700.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-10 14:08:52", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 214,700.00(付)", status: "待确认", pair: "金牛煤业 ↔ 金牛贸易", subject: "其他应付(待复核)", batch: "JH-202607-021", note: "贸易侧已确认收款,科目待双方复核(应付 / 其他应付)。" } }, { date: "2026-07-13", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "国网河南省电力公司新密市供电公司", summary: "7 月工业电费", serial: "ICBC2026071300458820", status: "未归集 · 外部", statusPill: "pill-muted", amount: "1,528,300.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-13 10:19:05", peer: "国网河南省电力公司新密市供电公司", peerAcct: "工行新密支行 1706 0211 0900 4428 17", amount: "¥ 1,528,300.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外电费支出,不参与集团内部往来归集。" } }, { date: "2026-07-15", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "河南金牛新能源有限公司", summary: "场区租赁费返还(二季度)", serial: "BOC2026071500923314", status: "单边", statusPill: "pill-danger", amount: "95,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-15 11:06:33", peer: "河南金牛新能源有限公司", peerAcct: "工行郑州分行 1704 0512 0900 2266 49", amount: "¥ 95,000.00(收)", status: "单边", pair: "金牛煤业 ↔ 金牛新能源", subject: "其他应收(煤业侧)", batch: "—(待归集)", note: "新能源侧尚未提报对应付款流水,形成单边。" } }, { date: "2026-07-18", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "国家税务总局新密市税务局", summary: "增值税及附加税费(6 月属期)", serial: "ICBC2026071800506639", status: "未归集 · 外部", statusPill: "pill-muted", amount: "2,073,450.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-18 13:29:40", peer: "国家税务总局新密市税务局", peerAcct: "国库专户", amount: "¥ 2,073,450.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "税费缴库,不参与集团内部往来归集。" } }, { date: "2026-07-21", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "永城煤电控股集团有限公司", summary: "块煤销售款(年度长协第 7 批)", serial: "BOC2026072101054472", status: "未归集 · 外部", statusPill: "pill-muted", amount: "3,864,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-21 09:35:12", peer: "永城煤电控股集团有限公司", peerAcct: "工行永城分行 1702 0218 0902 3477 12", amount: "¥ 3,864,000.00(收)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外块煤销售回款,不参与集团内部往来归集。" } }, { date: "2026-07-24", company: "金牛煤业", bank: "交通银行", account: "7710", acctLabel: "交行 · 尾号 7710", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "临时往来款归还", serial: "BCM2026072400088116", status: "待确认", statusPill: "pill-warn", amount: "1,500,000.00", amtClass: "amt-in", detail: { bank: "交通银行", account: "河南金牛煤业有限公司 · 一般户 4110 6120 0181 0077 10(账户待审核)", time: "2026-07-24 14:52:26", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 1,500,000.00(收)", status: "待确认", pair: "金牛煤业 ↔ 金牛置业", subject: "其他应收(煤业侧)", batch: "JH-202607-021", note: "交行 7710 账户尚处待审核,归集结果以账户审核通过后为准。" } }, { date: "2026-07-27", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "煤业职工工资代发(2026 年 7 月)", summary: "7 月职工工资及奖金代发,共 612 人", serial: "ICBC2026072700582241", status: "未归集 · 外部", statusPill: "pill-muted", amount: "2,416,780.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-27 10:11:57", peer: "煤业职工工资代发(2026 年 7 月)", peerAcct: "代发专户", amount: "¥ 2,416,780.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "工资代发,不参与集团内部往来归集。" } }, { date: "2026-07-30", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "河南金牛农业科技发展有限公司", summary: "临时周转借款(约定 8 月归还)", serial: "BOC2026073001187903", status: "单边", statusPill: "pill-danger", amount: "800,000.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-30 16:32:08", peer: "河南金牛农业科技发展有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 800,000.00(付)", status: "单边", pair: "金牛煤业 ↔ 金牛农业", subject: "其他应收(煤业侧)", batch: "—(待归集)", note: "农业 7 月未提交流水,暂无对方侧证据。" } }, ]; function currentFlowData() { return portal === "admin" ? FLOW_DEMO : COMPANY_FLOWS; } function renderFlowRows() { const tbody = $("#flowTable tbody"); if (!tbody) return; const data = currentFlowData(); tbody.innerHTML = data.map((row, index) => { const companyCell = portal === "admin" ? `${row.company}` : ""; return ` ${row.date} ${companyCell} ${row.acctLabel} ${row.dir} ${row.peer} ${row.summary} ${row.serial} ${row.status} ¥ ${row.amount} `; }).join(""); const count = $("#flowCount"); if (count) count.textContent = `共 ${data.length} 笔`; const empty = $("#flowEmpty"); if (empty) empty.hidden = data.length > 0; const sum = $("#flowSum"); if (sum) { let inflow = 0, outflow = 0; data.forEach((row) => { if (row.dir === "收") inflow += Number(row.amount.replace(/,/g, "")); else outflow += Number(row.amount.replace(/,/g, "")); }); sum.innerHTML = data.length ? `收 +¥ ${formatCurrency(inflow)} · 付 -¥ ${formatCurrency(outflow)}` : ""; } } function openFlowDetail(index) { const modal = $("#tx-modal"); if (!modal) return; const row = currentFlowData()[index]; if (!row) return; $("#tx-modal-sub").textContent = `${row.company} · ${row.acctLabel} · ${row.date}`; const d = row.detail; $("#d-serial").textContent = row.serial; $("#d-bank").textContent = d.bank; $("#d-account").textContent = d.account; $("#d-time").textContent = d.time; $("#d-peer").textContent = d.peer; $("#d-peer-acct").textContent = d.peerAcct; $("#d-amount").textContent = d.amount; $("#d-status").textContent = d.status; $("#d-pair").textContent = d.pair; $("#d-subject").textContent = d.subject; $("#d-batch").textContent = d.batch; $("#d-note").textContent = d.note; modal.classList.add("open"); } function visibleRows(table) { return $$('tbody tr', table).filter((row) => !row.hidden); } function filterFlows() { const table = $("#flowTable"); if (!table) return; const company = $("#flowCompany")?.value || "全部公司"; const bank = $("#flowBank")?.value || "全部银行"; const account = $("#flowAccount")?.value || "全部账户"; const startDate = $("#flowStart")?.value || "0000-01-01"; const endDate = $("#flowEnd")?.value || "9999-12-31"; const keyword = $("#flowKeyword")?.value.trim().toLowerCase() || ""; if (startDate > endDate) { showToast("日期范围无效", "开始日期不能晚于结束日期", "warn"); return; } let count = 0; $$("tbody tr", table).forEach((row) => { const rowDate = row.dataset.date || $("td", row).textContent.trim().replaceAll(".", "-"); const matchesCompany = company === "全部公司" || row.dataset.company === company; const matchesBank = bank === "全部银行" || row.dataset.bank === bank; const matchesAccount = account === "全部账户" || row.dataset.account === account || (row.dataset.account === undefined && row.textContent.includes(account)); const matchesDate = rowDate >= startDate && rowDate <= endDate; const matchesKeyword = !keyword || row.textContent.toLowerCase().includes(keyword); row.hidden = !(matchesCompany && matchesBank && matchesAccount && matchesDate && matchesKeyword); if (!row.hidden) count += 1; }); $("#flowCount").textContent = count === 0 ? "共 0 条 · 无匹配记录" : `共 ${count} 笔 · 本页 1-${count}`; const empty = $("#flowEmpty"); if (empty) empty.hidden = count > 0; const sum = $("#flowSum"); if (sum) { let inflow = 0, outflow = 0; $$("tbody tr", table).forEach((row) => { if (row.hidden) return; const amount = Number(($("td:last-child", row)?.textContent || "").replace(/[^\d.]/g, "")); if (row.dataset.dir === "收") inflow += amount; else outflow += amount; }); sum.innerHTML = count ? `收 +¥ ${formatCurrency(inflow)} · 付 -¥ ${formatCurrency(outflow)}` : ""; } showToast("查询完成", `当前显示 ${count} 笔流水`); } function exportFlows() { const table = $("#flowTable"); const rows = visibleRows(table); const headers = ["交易日期", "公司", "银行账户", "方向", "对方户名", "摘要", "银行流水号", "归集状态", "金额", "导入批次", "源行定位"]; const records = rows.map((row, index) => { const cells = $$('td', row).map((cell) => cell.innerText.replace(/\n/g, " ").trim()); const imp = `IMP-DEMO-${String(index + 1).padStart(3, "0")}`; const loc = `Sheet1!R${index + 8}`; const companyName = state.me?.company_name || "本公司"; if (portal === "admin") return [...cells.slice(0, 9), imp, loc]; return [cells[0], companyName, cells[1], ...cells.slice(2), imp, loc]; }); const csv = [headers, ...records].map((record) => record.map((value) => `"${String(value ?? "").replace(/"/g, '""')}"`).join(",")).join("\r\n"); const link = document.createElement("a"); link.href = URL.createObjectURL(new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" })); link.download = `${portal === "admin" ? "集团" : (state.me?.company_name || "本公司")}银行流水_202607.csv`; link.click(); URL.revokeObjectURL(link.href); showToast("导出已生成", `共 ${rows.length} 笔,已保留银行标识与源行定位`, "success"); } function initFlowTools() { $("#applyFlowFilters")?.addEventListener("click", filterFlows); $("#exportFlows")?.addEventListener("click", exportFlows); renderFlowRows(); $("#resetFlowFilters")?.addEventListener("click", () => { const company = $("#flowCompany"); if (company) company.value = "全部公司"; $("#flowBank").value = "全部银行"; $("#flowAccount").value = "全部账户"; $("#flowStart").value = "2026-07-01"; $("#flowEnd").value = portal === "admin" ? "2026-07-31" : "2026-08-20"; $("#flowKeyword").value = ""; filterFlows(); }); $("#flowTable tbody")?.addEventListener("click", (event) => { const row = event.target.closest("tr[data-flow-idx]"); if (row) openFlowDetail(Number(row.dataset.flowIdx)); }); $("#tx-modal-close")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open")); $("#tx-modal-ok")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open")); $("#tx-modal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); }); } function resetUpload() { state.selectedFile = null; state.parseResult = null; $("#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; $("#parseButton span").textContent = "开始解析"; delete $("#parseButton").dataset.stage; } } function updateParseButton() { const button = $("#parseButton"); if (button) button.disabled = !(state.selectedFile && $("#accountSelect").value); } function acceptFile(file) { if (!file) return; const extension = file.name.split(".").pop().toLowerCase(); if (!["xls", "xlsx"].includes(extension)) { showToast("文件格式不支持", "请选择银行导出的 .xls 或 .xlsx 文件", "danger"); return; } state.selectedFile = file; state.parseResult = null; $("#fileName").textContent = file.name; $("#fileMeta").textContent = `${(file.size / 1024).toFixed(1)} KB · 等待表头识别`; $("#filePreview").hidden = false; $("#dropzone").hidden = true; $("#parseResult").hidden = true; delete $("#parseButton").dataset.stage; updateParseButton(); } async function parseFile() { const formData = new FormData(); formData.append("file", state.selectedFile); const selectedAccount = $("#accountSelect")?.selectedOptions?.[0]; if (selectedAccount?.dataset.accountId) { formData.append("bank_account_id", selectedAccount.dataset.accountId); } let result; let parsed = false; try { const response = await fetch("/api/parse", { method: "POST", body: formData }); if (response.status === 401 || response.status === 403) { window.location.href = "index.html"; return; } result = await response.json(); parsed = response.ok && ["parsed", "duplicate"].includes(result.status); } catch { result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" }; } state.parseResult = result; renderParseResult(result, parsed); } 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("info", parsed); panel.classList.toggle("warn", !parsed); const parseIcon = $("use", panel); if (parseIcon) parseIcon.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("div"); item.className = "list-row"; const meta = sheetStatusMeta(sheet); const main = document.createElement("div"); main.className = "lr-main"; const title = document.createElement("div"); title.className = "lr-title"; title.textContent = sheet.sheet_name; main.append(title); const details = document.createElement("div"); details.className = "lr-sub"; 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}`; } main.append(details); const badge = document.createElement("span"); badge.className = `pill ${pillClass(meta.className)}`; badge.textContent = meta.label; item.append(main, badge); if (sheet.review_status === "pending") { const actions = document.createElement("div"); actions.className = "lr-side"; actions.style.cssText = "display:flex;gap:6px;flex:none;"; if (sheet.outcome === "parsed") { const confirmButton = document.createElement("button"); confirmButton.type = "button"; confirmButton.className = "btn btn-sm btn-primary"; confirmButton.textContent = "确认"; confirmButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "confirm")); actions.append(confirmButton); } const ignoreButton = document.createElement("button"); ignoreButton.type = "button"; ignoreButton.className = "btn btn-sm"; ignoreButton.textContent = "忽略"; ignoreButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "ignore")); actions.append(ignoreButton); item.append(actions); } return item; } async function sheetReviewAction(batchId, sheetName, decision) { const payload = { sheets: [sheetName] }; if (decision === "ignore") { const reason = (window.prompt("请填写忽略原因(必填):", "") || "").trim(); if (!reason) { showToast("忽略未提交", "必须填写忽略原因", "warn"); 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 || "请稍后重试", "danger"); return; } showToast(decision === "confirm" ? "工作表已确认" : "工作表已忽略", `${sheetName}`, "success"); 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) { resetUpload(); 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 || "请稍后重试", "danger"); return; } button.disabled = false; if (Array.isArray(outcome.sheets)) renderSheetList(outcome.sheets, batchId); await loadImportBatches(); resetUpload(); showView("upload"); showToast("流水已确认", `${outcome.updated.length} 个工作表已确认;未确认的工作表不参与计算`, "success"); } function submitImportException() { resetUpload(); showView("upload"); showToast("解析异常未入账", `${state.selectedFile?.name || "该文件"} 不会进入匹配与计算,请核对模板后重新导出`, "warn"); } function renderBatchRow(batch) { const row = document.createElement("tr"); row.dataset.batchId = batch.id; const idCell = document.createElement("td"); const id = document.createElement("span"); id.className = "cell-main num"; id.textContent = `IMP-${String(batch.id).padStart(6, "0")}`; const file = document.createElement("span"); file.className = "cell-sub"; 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.className = "num"; period.textContent = batch.period_start && batch.period_end ? `${batch.period_start} ~ ${batch.period_end}` : "—"; const count = document.createElement("td"); count.className = "num-col"; 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 = `${coverageStatus.label}`; 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} 个忽略`); if (statusParts.length) { parseState.innerHTML = `${statusParts.join("、")}`; } else { parseState.innerHTML = '解析成功'; } const time = document.createElement("td"); time.className = "meta"; time.textContent = String(batch.created_at || "").slice(0, 16).replace("T", " "); const action = document.createElement("td"); action.innerHTML = ''; row.append(idCell, bank, period, count, coverage, parseState, time, action); return row; } async function loadImportBatches() { const tbody = $("#importRows"); if (!tbody) return; showTableLoading(tbody, 8); const response = await fetch("/api/batches").catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } if (!response?.ok) { showTableError(tbody, 8); return; } const result = await response?.json().catch(() => ({})); const batches = Array.isArray(result?.batches) ? result.batches : []; state.batches = batches; tbody.replaceChildren(...batches.map(renderBatchRow)); const foot = $("#importFoot"); if (foot) foot.textContent = batches.length ? `共 ${batches.length} 个批次` : "暂无批次"; } function refreshWorkspacePending() { const card = $("#workspaceTodos"); if (!card) return; const count = $$(".list-row", card).length; const status = $("#workspacePendingStatus"); if (status) status.textContent = `${count} 项待处理`; } function initReconcile() { const matchCards = $$("[data-match-card]"); const subjectRows = $$("[data-subject-row]"); if (!matchCards.length && !subjectRows.length) return; const noticeTitle = $("#notice-title"); const noticeBody = $("#notice-body"); const notice = $("#blocking-notice"); const matchedSummary = $("#matched-summary"); const matchedList = $("#matched-list"); const matchStack = $("#match-stack"); let pendingMatch = matchCards.length; let pendingSubject = subjectRows.length; function refreshCounts() { const countMatch = $("#count-match"); const countSubject = $("#count-subject"); if (countMatch) countMatch.textContent = pendingMatch; if (countSubject) countSubject.textContent = pendingSubject; const total = pendingMatch + pendingSubject; const badge = $('.side-nav a[data-view="reconcile"] .nav-badge'); if (badge) { badge.textContent = total; badge.style.display = total ? "" : "none"; } if (noticeTitle) { noticeTitle.textContent = total ? `${total} 项待确认,是 7 月结账的阻断项` : "全部确认完成"; } if (noticeBody) { noticeBody.textContent = total ? `含单边流水匹配 ${pendingMatch} 项、科目确认 ${pendingSubject} 项。请于 2026-08-29(7 月顺延结账日)前处理完毕,否则集团无法对贵公司执行 7 月结账。` : "本公司 2026-07 账期已具备结账条件,集团将于 08-29 统一执行结账。"; } if (notice) { notice.classList.toggle("warn", total > 0); notice.classList.toggle("success", total === 0); } if (pendingMatch === 0) $('[data-task-type="match"]', $("#workspaceTodos"))?.remove(); const matchRow = $('[data-task-type="match"]', $("#workspaceTodos")); if (matchRow) { const title = $(".lr-title", matchRow); if (title) title.textContent = `处理 ${pendingMatch} 笔单边流水确认`; } const subjectRow = $('[data-task-type="subject"]', $("#workspaceTodos")); if (subjectRow) { const title = $(".lr-title", subjectRow); if (title) title.textContent = `确认 ${pendingSubject} 笔其他应收科目`; } const flowState = $("#workspaceConfirmState"); if (flowState) flowState.textContent = total ? `待处理 ${total} 笔` : "已完成"; const flowMeta = $("#workspaceConfirmMeta"); if (flowMeta) flowMeta.textContent = total ? `单边流水 ${pendingMatch} 笔 · 科目确认 ${pendingSubject} 笔` : "已全部确认,等待集团结账"; refreshWorkspacePending(); } matchCards.forEach((card) => { const confirmButton = $("[data-match-confirm]", card); const radios = $$('input[type="radio"]', card); radios.forEach((radio) => radio.addEventListener("change", () => { if (confirmButton) confirmButton.disabled = false; })); confirmButton?.addEventListener("click", () => { const summaryText = confirmButton.dataset.matchSummary || "已确认匹配"; const row = document.createElement("div"); row.className = "list-row"; const main = document.createElement("div"); main.className = "lr-main"; const title = document.createElement("div"); title.className = "lr-title"; title.textContent = summaryText; main.append(title); const pill = document.createElement("span"); pill.className = "pill pill-success lr-side"; pill.textContent = "已匹配"; row.append(main, pill); matchedList.append(row); matchedSummary.style.display = ""; card.remove(); pendingMatch -= 1; if (pendingMatch === 0) { const empty = document.createElement("div"); empty.className = "empty"; empty.innerHTML = '
单边流水已全部匹配
确认结果将同步至集团审核中心复核
'; matchStack.append(empty); } refreshCounts(); showToast("匹配已确认", "待办状态、操作人、时间和依据已同步更新", "success"); }); }); $$(".subject-confirm-btn").forEach((button) => { button.addEventListener("click", () => { const row = button.closest("[data-subject-row]"); const select = $("select", row); const subject = select.value; select.disabled = true; button.disabled = true; button.textContent = "已确认"; const statusCell = $(".subject-status", row); statusCell.innerHTML = `已确认 · ${subject}`; pendingSubject -= 1; refreshCounts(); showToast("科目已确认", "待办状态、操作人、时间和依据已同步更新", "success"); }); }); const tabMatch = $("#tab-match"); const tabSubject = $("#tab-subject"); const panelMatch = $("#panel-match"); const panelSubject = $("#panel-subject"); function switchTab(which) { tabMatch?.classList.toggle("active", which === "match"); tabSubject?.classList.toggle("active", which === "subject"); tabMatch?.setAttribute("aria-pressed", String(which === "match")); tabSubject?.setAttribute("aria-pressed", String(which === "subject")); if (panelMatch) panelMatch.style.display = which === "match" ? "" : "none"; if (panelSubject) panelSubject.style.display = which === "subject" ? "" : "none"; } tabMatch?.addEventListener("click", () => switchTab("match")); tabSubject?.addEventListener("click", () => switchTab("subject")); refreshCounts(); } function initNotifications() { const tabs = $("#notice-tabs"); const list = $("#notice-list"); if (!tabs || !list) return; const rows = $$(".list-row", list); const emptyBox = $("#notice-empty"); let currentFilter = "all"; const STATUS_PILL = { unread: "pill-danger", doing: "pill-warn", done: "pill-success" }; const STATUS_LABEL = { unread: "未读", doing: "处理中", done: "已完成" }; function counts() { const c = { all: rows.length, unread: 0, doing: 0, done: 0 }; rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; }); return c; } function refreshCounts() { const c = counts(); $("#count-all").textContent = c.all; $("#count-unread").textContent = c.unread; $("#count-doing").textContent = c.doing; $("#count-done").textContent = c.done; const navBadge = $('.side-nav a[data-view="notifications"] .nav-badge'); if (navBadge) { navBadge.textContent = c.unread; navBadge.style.display = c.unread ? "" : "none"; } } function applyFilter() { let visible = 0; rows.forEach((row) => { const show = currentFilter === "all" || row.getAttribute("data-status") === currentFilter; row.style.display = show ? "" : "none"; if (show) visible += 1; }); emptyBox.style.display = visible === 0 ? "" : "none"; } function setStatus(row, status) { row.setAttribute("data-status", status); const pill = $(".pill", row); if (pill) { pill.className = `pill ${STATUS_PILL[status]}`; pill.textContent = STATUS_LABEL[status]; } } tabs.addEventListener("click", (event) => { const btn = event.target.closest("button[data-filter]"); if (!btn) return; $$("button", tabs).forEach((b) => { b.classList.remove("active"); b.setAttribute("aria-pressed", "false"); }); btn.classList.add("active"); btn.setAttribute("aria-pressed", "true"); currentFilter = btn.dataset.filter; applyFilter(); }); list.addEventListener("click", (event) => { const btn = event.target.closest(".btn-mark-read"); if (!btn) return; const row = btn.closest(".list-row"); setStatus(row, "done"); btn.remove(); refreshCounts(); applyFilter(); }); $("#mark-all-read")?.addEventListener("click", () => { rows.forEach((row) => { if (row.getAttribute("data-status") !== "unread") return; setStatus(row, "done"); $(".btn-mark-read", row)?.remove(); }); refreshCounts(); applyFilter(); showToast("通知已全部标为已读", "", "success"); }); refreshCounts(); applyFilter(); } function openAccountDetail(account) { const meta = companyAccountMeta(account.status); $("#ad-title").textContent = `${account.bank_name || ""} · 尾号 ${accountTail(account.account_number_masked)}`; $("#ad-sub").textContent = "本公司 · 登记账户明细"; $("#ad-bank").textContent = account.bank_name || "—"; $("#ad-tail").textContent = `尾号 ${accountTail(account.account_number_masked)}`; $("#ad-type").textContent = account.account_type || "—"; $("#ad-branch").textContent = account.account_name || "—"; $("#ad-reg").textContent = String(account.created_at || "").slice(0, 10) || "—"; $("#ad-status").textContent = meta.status.label; $("#ad-audit").textContent = meta.audit.label; $("#ad-purpose").textContent = "—"; $("#ad-effective").textContent = account.effective_from || "待审核确定"; $("#ad-range").textContent = account.usable ? "尚未上传" : "—"; $("#ad-reason").textContent = account.status === "returned" && account.review_reason ? account.review_reason : "—"; $("#modal-account-detail")?.classList.add("open"); } function initCompany() { renderCompanyManualRecords(); loadCompanyAccounts(); loadImportBatches(); function openModal(id) { $("#" + id)?.classList.add("open"); } function closeModal(id) { $("#" + id)?.classList.remove("open"); } $$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close))); $$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); })); document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); }); $$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => showView("upload"))); // ── 流水导入:多步流程(选文件 → 解析 → 分 sheet 审核 → 确认) ── $("#accountSelect")?.addEventListener("change", updateParseButton); $("#fileInput")?.addEventListener("change", (event) => acceptFile(event.target.files[0])); $("#removeFile")?.addEventListener("click", () => { state.selectedFile = null; $("#fileInput").value = ""; $("#filePreview").hidden = true; $("#dropzone").hidden = false; $("#parseResult").hidden = true; updateParseButton(); }); const dropzone = $("#dropzone"); if (dropzone) { ["dragenter", "dragover"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add("dragover"); })); ["dragleave", "drop"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove("dragover"); })); dropzone.addEventListener("click", () => $("#fileInput").click()); dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); $("#fileInput").click(); } }); dropzone.addEventListener("drop", (event) => acceptFile(event.dataTransfer.files[0])); } $("#uploadForm")?.addEventListener("submit", async (event) => { event.preventDefault(); 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) { resetUpload(); showView("upload"); await loadImportBatches(); } else { submitImportException(); } return; } $("#parseButton").disabled = true; $("#parseButton span").textContent = "正在识别表头..."; await parseFile(); }); // ── 导入批次详情弹窗 ── $("#importRows")?.addEventListener("click", (event) => { const btn = event.target.closest("[data-batch-view]"); if (!btn) return; const tr = btn.closest("tr"); const batch = (state.batches || []).find((b) => String(b.id) === String(tr?.dataset.batchId)); if (!batch) return; $("#mb-sub").textContent = `IMP-${String(batch.id).padStart(6, "0")} · ${batch.original_filename || ""}`; $("#mb-id").textContent = `IMP-${String(batch.id).padStart(6, "0")}`; $("#mb-bank").textContent = batch.bank_name || "—"; $("#mb-period").textContent = batch.period_start && batch.period_end ? `${batch.period_start} ~ ${batch.period_end}` : "—"; $("#mb-count").textContent = `${batch.confirmed_transactions ?? 0} 条`; $("#mb-cover").textContent = batch.status === "exception" ? "未导入" : batch.pending_sheets > 0 ? `${batch.pending_sheets} 个待确认` : batch.confirmed_sheets > 0 ? "已确认" : "待处理"; $("#mb-parse").textContent = batch.exception_sheets > 0 ? `${batch.exception_sheets} 个异常` : batch.ignored_sheets > 0 ? `${batch.ignored_sheets} 个忽略` : "解析成功"; $("#mb-time").textContent = String(batch.created_at || "").slice(0, 16).replace("T", " "); openModal("modal-batch"); }); // ── 手工记录:提交 + 撤回 ── $("#manualEntryForm")?.addEventListener("submit", (event) => { event.preventDefault(); const form = event.currentTarget; const data = new FormData(form); const evidence = data.get("evidence"); if (evidence instanceof File && evidence.size > 20 * 1024 * 1024) { showToast("证明附件超过限制", "请选择不超过 20 MB 的文件", "warn"); return; } const records = readStoredRecords(storageKeys.manual); const record = { id: `MR-${Date.now().toString().slice(-10)}`, company: "A公司", transactionDate: String(data.get("transactionDate")), direction: String(data.get("direction")), amount: Number(data.get("amount")), sourceAccount: String(data.get("sourceAccount")), counterpartyType: String(data.get("counterpartyType")), counterparty: String(data.get("counterparty")).trim(), counterpartyAccount: String(data.get("counterpartyAccount") || "").trim(), subject: String(data.get("subject")), summary: String(data.get("summary")).trim(), remark: String(data.get("remark")).trim(), bankReference: String(data.get("bankReference") || "").trim(), evidenceName: evidence instanceof File ? evidence.name : "", status: "待总账复核", createdAt: new Date().toLocaleString("zh-CN", { hour12: false }), }; records.push(record); if (!writeStoredRecords(storageKeys.manual, records)) return; renderCompanyManualRecords(); form.reset(); showToast("手工记录已提交", "总账复核前不会纳入公司间往来计算", "success"); }); let manualWithdrawRow = null; $("#manualRecordRows")?.addEventListener("click", (event) => { const btn = event.target.closest("button[data-action='withdraw']"); if (!btn) return; const tr = btn.closest("tr"); manualWithdrawRow = tr; const cells = tr.cells; $("#wd-date").textContent = cells[0]?.textContent.trim() || "—"; $("#wd-peer").textContent = cells[2]?.textContent.trim() || "—"; $("#wd-amount").textContent = cells[4]?.textContent.trim() || "—"; $("#wd-summary").textContent = cells[5]?.textContent.trim() || "—"; openModal("withdraw-modal"); }); $("#wd-confirm")?.addEventListener("click", () => { if (manualWithdrawRow) { const id = manualWithdrawRow.dataset.storedRecord; if (id) { const records = readStoredRecords(storageKeys.manual).filter((r) => r.id !== id); writeStoredRecords(storageKeys.manual, records); } else { manualWithdrawRow.remove(); } manualWithdrawRow = null; } closeModal("withdraw-modal"); renderCompanyManualRecords(); showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success"); }); // ── 往来确认 ── initReconcile(); // ── 通知 ── initNotifications(); // ── 银行账户 ── $("#openAccountDialog")?.addEventListener("click", () => openModal("accountDialog")); $("#account-tbody")?.addEventListener("click", (event) => { const btn = event.target.closest("[data-account-view]"); if (!btn) return; const tr = btn.closest("tr"); const account = (state.accounts || []).find((a) => String(a.id) === String(tr?.dataset.accountId)); if (!account) return; openAccountDetail(account); }); $("#accountForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); const response = await fetch("/api/company/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bank_name: String(data.get("bank") || "").trim(), account_type: String(data.get("type") || ""), account_number: String(data.get("accountNumber") || ""), start_date: String(data.get("startDate") || ""), }), }).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 || "请稍后重试", "danger"); return; } closeModal("accountDialog"); event.currentTarget.reset(); await loadCompanyAccounts(); showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success"); }); } if (portal === "entry") { initEntry(); } else { initAuthGuard().then((allowed) => { if (!allowed) return; initShell(); initFlowTools(); if (portal === "admin") { initPairQueries(); initAdmin(); } else { initCompany(); } }); }