const $ = (selector, scope) => { const root = scope ?? (typeof document !== "undefined" ? document : null); return root ? root.querySelector(selector) : null; }; const $$ = (selector, scope) => { const root = scope ?? (typeof document !== "undefined" ? document : null); return root ? [...root.querySelectorAll(selector)] : []; }; const portal = (typeof document !== "undefined" && document.body) ? (document.body.dataset.portal || "entry") : "entry"; const viewNames = portal === "admin" ? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" } : { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", balances: "往来余额", 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 = (typeof window !== "undefined" && window.matchMedia) ? window.matchMedia("(prefers-reduced-motion: reduce)") : { matches: true, addEventListener() {}, removeEventListener() {} }; 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-heading > *", ".metric-card", ".period-ribbon", ".company-alert", ".admin-dashboard-grid > *", ".company-dashboard-grid > *", ".company-ledger-panel", ".query-band", ".filter-bar", ".filter-grid", ".pair-report", ".panel", ".work-progress", ".reconcile-summary", ".account-directory > article", ]; const elements = [...new Set(selectors.flatMap((selector) => [...view.querySelectorAll(selector)]))] .filter((element) => !element.closest(".panel") || element.matches(".panel")); elements.forEach((element, index) => { element.getAnimations().forEach((animation) => animation.cancel()); // metric-card 的 3D 倾斜与悬停倾斜由 CSS 控制,入场动画只做淡入, // 否则 fill:both 的 translateY(0) 会覆盖 CSS 的 transform,导致倾斜失效。 const keyframes = element.classList.contains("metric-card") ? [{ opacity: 0 }, { opacity: 1 }] : [ { 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 }); $$(".nav-item", $("#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" }, ); }); $$(".company-ledger").forEach((ledger) => { ledger.addEventListener("toggle", () => { if (!ledger.open) return; $(".ledger-breakdown", ledger)?.animate( [{ opacity: 0, transform: "translateY(-8px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 260, easing: "cubic-bezier(.22,1,.36,1)" }, ); }); }); } 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("本机演示数据保存失败", "请检查浏览器是否允许本地存储"); 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 accountTail(masked) { return String(masked || "").replace(/^\*+/, ""); } function formatCurrency(value) { return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function showToast(title, detail = "") { const region = $("#toastRegion"); if (!region) return; const toast = document.createElement("div"); toast.className = "toast"; const heading = document.createElement("strong"); heading.textContent = title; toast.append(heading); if (detail) { const description = document.createElement("small"); description.textContent = detail; toast.append(description); } region.append(toast); window.setTimeout(() => toast.remove(), 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)); $$(".nav-item[data-view]").forEach((item) => { const active = item.dataset.view === view; item.classList.toggle("is-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 = { "gap-a": { tag: ["danger", "高风险"], title: "A公司 · 工行账户断档", desc: "工商银行 9481 缺少 07.01—07.21 流水,已影响 7 月结账。", fields: [["公司", "A公司"], ["账户", "工商银行 · 9481"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账 · 账户覆盖 · 双边匹配"], ["当前状态", "已逾期 2 天"]], tip: "建议先向 A公司出纳发送补传提醒,补齐后在审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] }, "match-bd": { tag: ["warning", "中风险"], title: "B公司 ↔ D公司 · 单边待匹配", desc: "D公司侧流水已到,B公司侧尚未确认,合计 312.00 万元。", fields: [["本方", "B公司"], ["对方", "D公司"], ["笔数 / 金额", "6 笔 · 312.00 万元"], ["候选情况", "金额与日期存在 2 个候选"], ["当前状态", "今日新增"]], tip: "建议按账号优先核对候选流水,金额与日期相同者先确认。", action: ["去审核中心匹配", "audit"] }, "calib-f": { tag: ["warning", "中风险"], title: "F公司 · 起算区间待校准", desc: "01.01—01.16 无银行流水覆盖,公司已提交无业务说明。", fields: [["公司", "F公司"], ["账户", "农业银行 · 3650"], ["无覆盖期间", "2026.01.01—01.16"], ["现有依据", "公司已提交无业务说明"], ["当前状态", "待公司确认"]], tip: "无业务说明属于审计证据,复核通过后该区间标记为已校准,不生成银行流水。", action: ["去审核中心复核", "audit"] }, "task-upload": { tag: ["danger", "最紧急"], title: "补传工商银行流水", desc: "账户尾号 9481 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户", "工商银行 · 9481"], ["缺少期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月账户覆盖与双边匹配"], ["截止", "08.05 集团结账日前"]], tip: "从工商银行网银导出 7 月流水后直接上传,系统会自动识别表头并重新计算匹配。", action: ["去上传流水", "upload"] }, "task-match": { tag: ["warning", "待确认"], title: "确认 1 笔单边流水", desc: "07.18 转出 280.00 万元,系统找到 2 个对方候选。", fields: [["对方", "B公司"], ["日期 / 金额", "07.18 · 280.00 万元"], ["候选", "工商银行 9481(推荐)· 建设银行 2046"], ["核对点", "摘要与账号是否一致"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] }, "task-subject": { tag: ["warning", "待确认"], title: "确认往来科目", desc: "06.27 转出 600.00 万元,规则无法区分应收与其他应收。", fields: [["对方", "C公司"], ["日期 / 金额", "06.27 · 600.00 万元"], ["待确认", "应收 或 其他应收"], ["摘要", "资金调拨"]], tip: "科目只按确定性规则建议,拿不准时选“其他应收”并在说明里注明依据。", action: ["去确认科目", "reconcile"] }, "task-notice": { tag: ["neutral", "提醒"], title: "阅读总账提醒", desc: "管理员要求 08.08 前完成 7 月银行流水确认。", fields: [["来自", "系统管理员 · 今天 09:30"], ["处理期限", "2026.08.08"], ["关联事项", "断档补传 · 2 项待确认往来"]], tip: "完成补传和两项确认后,再提交公司确认即可。", action: ["查看通知", "notifications"] }, "acct-citic": { tag: ["success", "连续"], title: "中信银行 · 5316", desc: "基本户 · 起算日以来流水全部连续。", fields: [["账户类型", "基本户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "今天 09:42"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] }, "acct-abc": { tag: ["success", "连续"], title: "农业银行 · 3650", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 08:01"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] }, "acct-icbc": { tag: ["danger", "断档"], title: "工商银行 · 9481", desc: "一般户 · 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户类型", "一般户"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账与双边匹配"], ["最近导入", "07.31 16:18(6 月批次)"]], tip: "这是最紧急的一项:补齐后系统会自动重算覆盖与匹配。", action: ["去补传流水", "upload"] }, "acct-ccb": { tag: ["success", "连续"], title: "建设银行 · 0845", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 10:03"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] }, }; function initDetailDrawer() { const triggers = $$("[data-detail]"); if (!triggers.length) return; const drawer = document.createElement("aside"); drawer.className = "detail-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 = `status ${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.map(([label, value]) => { const row = document.createElement("div"); const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); dd.textContent = value; row.append(dt, dd); return row; })); 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-brand"), $(".entry-statement"), ...$$(".entry-facts > div"), form].filter(Boolean).forEach((element, index) => { element.animate( [{ opacity: 0, transform: "translateY(16px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 520, delay: index * 65, easing: "cubic-bezier(.22,1,.36,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; 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; 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("两次输入的新密码不一致。"); 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 || "修改密码失败,请稍后重试。"); 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 || "登录服务暂时不可用,请稍后重试。"); return; } if (result.must_change_password) { pendingRole = role; changeSection.hidden = false; action.textContent = "设置新密码并进入"; $('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) { // The company portal always shows the session-bound company, never a // hard-coded one. if (portal !== "company" || !me?.company_name) return; const context = $(".company-context"); if (context) { const mark = $("span", context); if (mark) mark.textContent = me.company_name.slice(0, 1); const name = $("strong", context); if (name) name.textContent = me.company_name; } } function initShell() { $$(".page-heading").forEach((heading) => { if (heading.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 = ''; heading.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 ? "关闭导航" : "打开导航"); }); }); $$(".nav-item").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", () => showView(button.dataset.view))); $$("[data-view-link]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.viewLink))); $$('a.nav-item[href="index.html"]').forEach((link) => link.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); }); $(".nav-item[data-view].is-active")?.setAttribute("aria-current", "page"); $("#globalSearch")?.addEventListener("input", (event) => { const view = $(`.app-view[data-page="${state.currentView}"]`); const query = event.target.value.trim().toLowerCase(); $$(".data-table tbody tr, .company-ledger, .notification-list article, .account-directory article", view).forEach((item) => { item.hidden = query ? !item.textContent.toLowerCase().includes(query) : false; }); }); 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(); } // --------------------------------------------------------------------------- // B-44 intercompany balances: directory, pair drill-down and evidence drawer // --------------------------------------------------------------------------- const b44 = { subjectLabels: { receivable: "应收", payable: "应付", other_receivable: "其他应收", other_payable: "其他应付" }, subjectCodes: { 应收: "receivable", 应付: "payable", 其他应收: "other_receivable", 其他应付: "other_payable" }, reasonLabels: { subject_review: "待确认科目", unmatched_single: "单边未决", manual_pending: "手工记录待审" }, from: "2026-01-01", cutoff: "2026-07-31", currency: "", companyNames: new Map(), }; function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); } function fmtMoney(value) { const num = Number(value); return Number.isFinite(num) ? num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"; } function fmtAbsMoney(value) { const num = Number(value); return Number.isFinite(num) ? Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"; } function homeCrumb() { return portal === "admin" ? "往来查询" : "往来余额"; } function resultDirection(signedOrDirection) { if (signedOrDirection === "receivable" || Number(signedOrDirection) > 0) return { className: "success", label: "应收" }; if (signedOrDirection === "payable" || Number(signedOrDirection) < 0) return { className: "danger", label: "应付" }; return { className: "neutral", label: "持平" }; } function eventIsNegative(event) { return event.posting_kind === "reversal" || Boolean(event.is_repayment); } function amountWithCurrency(value, currency, { signed = false, negative = false } = {}) { const abs = fmtAbsMoney(value); const sign = (signed && (negative || Number(value) < 0)) ? "−" : ""; const code = currency ? `${esc(currency)}` : ""; return `${code}${sign}${abs}`; } function accountCell(chip) { if (!chip || chip.visibility === "missing") return '源行缺失'; if (chip.visibility === "masked") return `${esc(chip.label || "按对方授权不可见")}`; return `${esc(chip.label || "—")}`; } function pickAccount(event, companyId, side) { const isPayer = Number(event.payer_company_id) === Number(companyId); if (side === "own") return isPayer ? event.payer_account : event.payee_account; return isPayer ? event.payee_account : event.payer_account; } function eventSummaryText(event) { const text = String(event.summary || "").trim(); if (text) return text; const reason = String(event.reason || "").trim(); return reason ? reason.split(/\r?\n/)[0] : "—"; } function sixLineHtml({ currency, debit, credit, signed, resultLabel, unresolved, cutoff, conservationNote }) { const direction = resultDirection(signed); const unresolvedBlock = unresolved && "html" in (unresolved || {}) ? unresolved : unresolvedText(unresolved); return `
期初余额期初不可用B-45 前无可靠期初
本期借方${amountWithCurrency(debit, currency)}
本期贷方${amountWithCurrency(credit, currency)}
期末结果${direction.label}${amountWithCurrency(signed, currency)}${esc(resultLabel || "期间净变动")}
未决金额${amountWithCurrency(unresolvedBlock.gross ?? unresolved?.gross_amount ?? 0, currency)}${unresolvedBlock.count ?? unresolved?.count ?? 0} 笔
截止日${fmtDate(cutoff)}${esc(conservationNote || "按币种独立展示")}
`; } function fmtDate(iso) { const text = String(iso || ""); return text.slice(0, 10).replace(/-/g, "."); } function b44Status(item) { if (item.opening?.status !== "unavailable") { return { label: "期末余额", className: "success" }; } return { label: "期间净变动", className: "neutral" }; } function subjectOf(event, companyId) { if (event.perspective_company_id === companyId) return event.subject_code; return { receivable: "payable", payable: "receivable", other_receivable: "other_payable", other_payable: "other_receivable" }[event.subject_code] || null; } function directionChip(direction) { const label = direction === "outgoing" ? "转出" : "转入"; const arrow = direction === "outgoing" ? "→" : "←"; return `${arrow} ${label}`; } function eventStateChip(state) { if (state === "confirmed") return '已确认'; if (state === "pending_subject") return '待确认科目'; return '未决'; } function postingLabel(event) { if (event.posting_kind === "reversal") return "冲销"; if (event.posting_kind === "adjustment") return "调整"; return "正常"; } function unresolvedText(unresolved) { const gross = Number(unresolved?.gross_amount || 0); const count = unresolved?.count || 0; if (!count) return { html: '未决 0.00', active: false, gross: 0, count: 0 }; const reasons = Object.entries(unresolved.by_reason || {}) .map(([key, value]) => `${b44.reasonLabels[key] || key} ${fmtAbsMoney(value.gross_amount)} · ${value.count} 笔`) .join(";"); return { html: `未决 ${fmtAbsMoney(gross)} · ${count} 笔${esc(reasons)}`, active: true, gross, count, }; } const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; function drawerFocusables(root) { return [...root.querySelectorAll(FOCUSABLE)].filter((node) => !node.hasAttribute("disabled") && node.getAttribute("aria-hidden") !== "true"); } function cycleTab(index, length, shift) { if (!length) return 0; if (shift) return (index - 1 + length) % length; return (index + 1) % length; } function drawerEscAction(depth) { return depth > 1 ? "back" : "close"; } function drawerState() { const drawer = $("#evidenceDrawer"); const scrim = $("#drawerScrim"); let opener = null; const stack = []; function paint() { if (!drawer || !stack.length) return; const layer = stack[stack.length - 1]; $("#drawerTitle", drawer).textContent = layer.title; const breadcrumb = $("#drawerBreadcrumb", drawer); breadcrumb.replaceChildren(); layer.crumbs.forEach((crumb, index) => { if (index) breadcrumb.append(document.createTextNode("/")); if (crumb.action) { const button = document.createElement("button"); button.type = "button"; button.textContent = crumb.label; button.addEventListener("click", crumb.action); breadcrumb.append(button); } else { const span = document.createElement("span"); span.textContent = crumb.label; breadcrumb.append(span); } }); const body = $("#drawerBody", drawer); body.innerHTML = layer.html; if (typeof layer.onMount === "function") layer.onMount(body); requestAnimationFrame(() => { const nodes = drawerFocusables(drawer); (nodes[0] || body).focus({ preventScroll: true }); if (layer.restoreSelector) { const target = body.querySelector(layer.restoreSelector); if (target) target.focus({ preventScroll: true }); } }); } function open(triggerElement) { if (!drawer) return; if (!stack.length) opener = triggerElement || opener; drawer.classList.add("is-open"); drawer.setAttribute("aria-hidden", "false"); scrim?.classList.add("is-open"); } function close({ restoreFocus = true } = {}) { if (!drawer) return; drawer.classList.remove("is-open"); drawer.setAttribute("aria-hidden", "true"); scrim?.classList.remove("is-open"); stack.length = 0; if (restoreFocus && opener && document.contains(opener)) opener.focus(); opener = null; } function back() { if (stack.length <= 1) { close(); return; } const leaving = stack.pop(); paint(); requestAnimationFrame(() => { const body = $("#drawerBody", drawer); const selector = leaving.triggerSelector; const target = selector ? body?.querySelector(selector) : null; if (target) target.focus({ preventScroll: true }); }); } function renderLayer(title, crumbs, html, onMount, options = {}) { if (!drawer) return; if (options.replaceTop && stack.length) stack.pop(); stack.push({ title, crumbs, html, onMount, triggerSelector: options.triggerSelector || null, restoreSelector: options.restoreSelector || null, }); paint(); } $$("[data-close-drawer]").forEach((button) => button.addEventListener("click", () => close())); drawer?.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.stopPropagation(); event.preventDefault(); if (drawerEscAction(stack.length) === "back") back(); else close(); return; } if (event.key !== "Tab" || !drawer.classList.contains("is-open")) return; const nodes = drawerFocusables(drawer); if (!nodes.length) return; const current = nodes.indexOf(document.activeElement); const next = cycleTab(current < 0 ? 0 : current, nodes.length, event.shiftKey); event.preventDefault(); nodes[next].focus(); }); return { open, close, back, renderLayer, stack }; } const drawer = drawerState(); function regionSkeleton(rows = 3) { return Array.from({ length: rows }, () => '
').join(""); } function regionError(container, message, retry) { if (!container) return; container.innerHTML = `
余额计算暂时不可用

${esc(message)}

`; container.querySelector("[data-retry]")?.addEventListener("click", retry); } function regionEmpty(container, title, hint, action) { if (!container) return; container.innerHTML = `
${esc(title)}

${esc(hint)}

${action || ""}
`; } async function apiJson(url, options) { const response = await fetch(url, options).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return null; } const body = await response?.json().catch(() => null); if (!response || !response.ok) { throw new Error(body?.message || "请求失败"); } return body; } function balanceQuery() { return `from=${encodeURIComponent(b44.from)}&cutoff=${encodeURIComponent(b44.cutoff)}${b44.currency ? `¤cy=${encodeURIComponent(b44.currency)}` : ""}`; } // --- Admin: company balance directory -------------------------------------- async function loadAdminBalances(container) { if (!container) return; container.setAttribute("aria-busy", "true"); container.innerHTML = regionSkeleton(3); try { const data = await apiJson(`/api/admin/intercompany/balances?${balanceQuery()}`); container.setAttribute("aria-busy", "false"); if (!data.items.length) { regionEmpty(container, "该区间无往来事件", "请调整统计区间后重试", ''); container.querySelector("[data-adjust-window]")?.addEventListener("click", () => document.querySelector("[data-balance-form] [name='from']")?.focus()); return; } renderBalanceDirectory(container, data.items); } catch (error) { container.setAttribute("aria-busy", "false"); regionError(container, error.message, () => loadAdminBalances(container)); } } function renderBalanceDirectory(container, items) { container.replaceChildren(...items.map((item) => { const details = document.createElement("details"); details.className = "company-ledger is-balances"; details.dataset.companyId = item.company_id; const direction = resultDirection(item.result.direction); const unresolved = unresolvedText(item.unresolved); const name = item.company_name || "未命名公司"; const summary = document.createElement("summary"); summary.innerHTML = ` ${esc(name.slice(0, 1))}${esc(name)}${esc(item.currency)} ${amountWithCurrency(item.period.debit, item.currency)} ${amountWithCurrency(item.period.credit, item.currency)} ${direction.label}${amountWithCurrency(item.result.signed_amount, item.currency)} ${unresolved.html} 截止 ${fmtDate(item.window.cutoff)} `; details.append(summary); const breakdown = document.createElement("div"); breakdown.className = "ledger-breakdown"; breakdown.setAttribute("aria-live", "polite"); breakdown.innerHTML = '
'; details.append(breakdown); details.addEventListener("toggle", () => { if (!details.open || details.dataset.loaded) return; details.dataset.loaded = "1"; loadCompanyBreakdown(breakdown, item, () => details.open = false); }); return details; })); } async function loadCompanyBreakdown(container, item, close) { try { const data = await apiJson(`/api/admin/intercompany/events?company_id=${item.company_id}&${balanceQuery()}`); const debit = []; const credit = []; const byPair = new Map(); for (const event of data.items) { if (event.state !== "confirmed") continue; if (event.currency && item.currency && event.currency !== item.currency) continue; const isPayer = event.payer_company_id === item.company_id; const counterpartyId = isPayer ? event.payee_company_id : event.payer_company_id; const counterpartyName = isPayer ? event.payee_company_name : event.payer_company_name; const subject = subjectOf(event, item.company_id); const key = `${counterpartyId}|${subject}|${event.currency}`; const bucket = byPair.get(key) || { counterpartyId, counterpartyName, subject, currency: event.currency, count: 0, amount: 0, isPayer }; bucket.count += 1; bucket.amount += Number(event.amount); byPair.set(key, bucket); (isPayer ? debit : credit).push(bucket); } if (!byPair.size) { regionEmpty(container, "暂无已确认明细", "该公司的确认往来将在科目确认后出现"); return; } const section = (title, total, rows, currency) => `

${title}

${amountWithCurrency(total, currency)}
${rows.map((row) => ` `).join("")}
`; const debitRows = [...new Map(debit.map((row) => [`${row.counterpartyId}|${row.subject}`, row])).values()]; const creditRows = [...new Map(credit.map((row) => [`${row.counterpartyId}|${row.subject}`, row])).values()]; container.innerHTML = section("借方明细", debitRows.reduce((sum, row) => sum + row.amount, 0), debitRows, item.currency) + section("贷方明细", creditRows.reduce((sum, row) => sum + row.amount, 0), creditRows, item.currency); container.querySelectorAll("[data-open-pair]").forEach((button) => { button.addEventListener("click", () => { const counterpartyId = Number(button.dataset.openPair); drawer.open(button); renderAdminPair(item.company_id, counterpartyId, item.company_name); }); }); } catch (error) { regionError(container, error.message, close); } } function eventTableHead() { return `交易日期方向科目本方账户对方账户摘要匹配状态金额`; } function eventRowHtml(event, companyId) { const subject = subjectOf(event, companyId) || event.own_subject; const direction = event.direction || (event.payer_company_id === companyId ? "outgoing" : "incoming"); const posting = event.posting_kind !== "normal" ? `${postingLabel(event)}` : ""; const otherName = event.counterparty_company_name || (event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name); const summary = eventSummaryText(event); return ` ${fmtDate(event.effective_at)}${posting} ${directionChip(direction)} ${event.state === "confirmed" ? esc(b44.subjectLabels[subject] || event.own_subject_label || "") : '待确认科目'} ${accountCell(pickAccount(event, companyId, "own"))} ${accountCell(pickAccount(event, companyId, "counterparty"))} ${esc(summary)} ${eventStateChip(event.state)} ${amountWithCurrency(event.amount, event.currency, { signed: true, negative: eventIsNegative(event) })} `; } function bindEventRows(root, tableId, companyId) { $$(`#${tableId} tbody tr.event-row`, root).forEach((row) => { const open = () => { renderEventEvidence(Number(row.dataset.eventId), companyId, { triggerSelector: `[data-event-id="${row.dataset.eventId}"]`, }); }; row.addEventListener("click", open); row.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); } }); }); } // --- Admin: pair detail + events ------------------------------------------- async function renderAdminPair(companyId, counterpartyId, companyName, extraCrumbs = []) { const otherName = b44.companyNames.get(counterpartyId) || "对方公司"; drawer.open(); drawer.renderLayer( `${companyName || "公司"} ↔ ${otherName}`, [{ label: homeCrumb(), action: () => drawer.close() }, { label: `${companyName || companyId} ↔ ${otherName}` }], regionSkeleton(4), ); try { const data = await apiJson(`/api/admin/intercompany/pairs/${companyId}/${counterpartyId}?${balanceQuery()}`); renderAdminPairBody(data, companyId, counterpartyId, extraCrumbs); } catch (error) { $("#drawerBody").innerHTML = `
余额计算暂时不可用

${esc(error.message)}

`; $("#drawerBody [data-retry]")?.addEventListener("click", () => renderAdminPair(companyId, counterpartyId, companyName, extraCrumbs)); } } function renderAdminPairBody(data, companyId, counterpartyId) { const a = data.companies.a; const b = data.companies.b; const lines = (data.items || []).map((item) => sixLineHtml({ currency: item.currency, debit: item.a.period.debit, credit: item.a.period.credit, signed: item.a.result.signed_amount, resultLabel: item.a.result.label, unresolved: item.unresolved, cutoff: data.window.cutoff, conservationNote: "双方守恒已校验", })).join(""); const first = data.items[0]; const subjectButtons = Object.values(first?.subjects || {}).map((subject) => ` `).join(""); const eventCount = (data.items || []).reduce((sum, item) => sum + (item.trace?.event_count || 0), 0); const body = ` ${lines}
${subjectButtons}
${eventTableHead()}
`; drawer.renderLayer( `${a.name} ↔ ${b.name}`, [ { label: homeCrumb(), action: () => drawer.close() }, { label: `${a.name} ↔ ${b.name}` }, ], body, (root) => { const urls = (data.items || []).map((item) => item.trace.events_url); loadPairEvents(root, companyId, counterpartyId, urls); $$("[data-subject]", root).forEach((button) => button.addEventListener("click", () => { $$("[data-subject]", root).forEach((item) => { const active = item === button; item.classList.toggle("is-active", active); item.setAttribute("aria-pressed", String(active)); }); const subject = button.dataset.subject; $$("#pairEventsTable tbody tr[data-subject-row]").forEach((row) => { row.hidden = subject !== "all" && row.dataset.subjectRow !== subject; }); })); }, { replaceTop: true }, ); } async function loadPairEvents(root, companyId, counterpartyId, urls) { const tbody = $("#pairEventsTable tbody", root); if (!tbody) return; tbody.setAttribute("aria-busy", "true"); const pageSize = 50; try { const pages = await Promise.all((Array.isArray(urls) ? urls : [urls]).map((url) => apiJson(`${url}&limit=${pageSize}`))); const items = pages.flatMap((page) => page.items || []); tbody.setAttribute("aria-busy", "false"); if (!items.length) { tbody.innerHTML = '
该区间无往来事件

当前公司对在统计区间内没有逐笔事件

'; return; } tbody.innerHTML = items.map((event) => eventRowHtml(event, companyId)).join(""); bindEventRows(root, "pairEventsTable", companyId); if (pages.some((page) => page.has_more)) { const footer = document.createElement("tr"); footer.innerHTML = '
超过 50 笔,请在服务端按截止日分段查看
'; tbody.append(footer); } } catch (error) { tbody.setAttribute("aria-busy", "false"); tbody.innerHTML = `
逐笔事件加载失败

${esc(error.message)}

`; } } // --- Evidence drawer --------------------------------------------------------- async function renderEventEvidence(eventId, companyId, options = {}) { const base = portal === "company" ? "/api/company/intercompany" : "/api/admin/intercompany"; drawer.renderLayer("事件证据", [ { label: homeCrumb(), action: () => drawer.close() }, { label: "返回上一层", action: () => drawer.back() }, ], regionSkeleton(5), null, { triggerSelector: options.triggerSelector }); try { const [detail, evidence] = await Promise.all([ apiJson(`${base}/events/${eventId}`), apiJson(`${base}/events/${eventId}/evidence`), ]); const event = detail.event; const direction = companyId ? (event.payer_company_id === companyId ? "outgoing" : "incoming") : null; const subjectLabel = companyId && event.subject_code ? (b44.subjectLabels[subjectOf(event, companyId)] || "") : (b44.subjectLabels[event.subject_code] || ""); const blocks = evidence.blocks.map(renderEvidenceBlock).join('
双边匹配 · 已去重为同一事件
'); drawer.renderLayer( `${fmtDate(event.effective_at)} 事件`, [ { label: homeCrumb(), action: () => drawer.close() }, { label: `${event.payer_company_name} ↔ ${event.payee_company_name}`, action: () => drawer.back() }, { label: fmtDate(event.effective_at) }, ], `
方向${direction ? directionChip(direction) : ""}${esc(subjectLabel)}
金额${amountWithCurrency(event.amount, event.currency, { signed: true, negative: eventIsNegative(event) })}
匹配状态${eventStateChip(detail.state)}
${blocks}
`, null, { replaceTop: true, triggerSelector: options.triggerSelector }, ); } catch (error) { $("#drawerBody").innerHTML = `
证据加载失败

${esc(error.message)}

`; $("#drawerBody [data-retry]")?.addEventListener("click", () => renderEventEvidence(eventId, companyId, options)); } } function renderEvidenceBlock(block) { if (block.visibility === "missing") { return `
对方源行缺失 · 当前为单边记录该侧没有可展示的银行或手工证据
`; } const sideLabel = block.side === "own" ? "本方" : "对方"; const kindLabel = block.source_kind === "manual" ? "手工记录" : "银行源行"; const title = `${sideLabel} · ${kindLabel}${block.visibility === "masked" ? "(按对方授权不可见)" : ""}`; if (block.visibility === "masked") { const fields = block.fields || {}; const rows = [ fields.company_name ? ["对方公司", fields.company_name] : null, fields.own_account_masked ? ["对方账号", fields.own_account_masked] : null, fields.state ? ["记录状态", fields.state] : null, ].filter(Boolean).map(([label, value]) => `
${esc(label)}
${esc(value)}
`).join(""); return `
${esc(title)}
${rows || "
可见范围
仅对方公司名称与脱敏账号
"}
`; } const fields = block.fields || {}; const pairs = []; if (block.source_kind === "bank") { pairs.push( ["银行文件", fields.original_filename], ["工作表", fields.sheet_name], ["源行号", fields.source_row], ["交易时间", fields.transaction_at], ["收入", fields.income], ["支出", fields.expense], ["本方账号", fields.own_account], ["本方户名", fields.own_name], ["对方账号", fields.counterparty_account_masked || fields.counterparty_account], ["对方户名", fields.counterparty_name], ["摘要", fields.summary], ["用途", fields.purpose], ["流水号", fields.reference], ["币种", fields.currency], ["观察角色", fields.role], ); } else { pairs.push( ["记录编号", fields.manual_record_id], ["提交公司", fields.company_name], ["对方公司", fields.counterparty_company_name], ["业务日期", fields.occurred_at], ["方向", fields.direction === "outgoing" ? "转出" : "转入"], ["金额", `${fields.amount} ${fields.currency}`], ["资金来源", fields.funding_source], ["建议科目", b44.subjectLabels[fields.requested_subject] || fields.requested_subject], ["摘要", fields.summary], ["补充说明", fields.reason], ["状态", fields.state], ); } const grid = pairs.filter(([, value]) => value !== null && value !== undefined && value !== "") .map(([label, value]) => `
${esc(label)}
${esc(value)}
`).join(""); return `
${esc(title)}
${grid}
`; } // --- Admin: subject-review and manual-record audit rows ---------------------- function appendSubjectReviewRow(item) { const tbody = $("#auditRows"); if (!tbody) return; const row = document.createElement("tr"); row.dataset.auditType = "科目"; row.dataset.recordKind = "subject-review"; row.dataset.ledgerEventId = item.ledger_event_id; row.dataset.perspectiveId = item.payer_company_id; row.dataset.expectedRevision = item.revision_id; row.dataset.company = item.payer_company_name; const suggestions = (item.suggestions || []).map((s) => `${s.suggested_company_name} · ${s.suggested_subject_label}`).join(";") || "无规则建议(需人工判定)"; row.dataset.suggestions = JSON.stringify(item.suggestions || []); row.dataset.summary = item.summary || ""; row.dataset.amount = String(item.amount || ""); row.dataset.currency = item.currency || ""; row.dataset.effectiveAt = item.effective_at || ""; row.innerHTML = ` ${esc(item.payer_company_name)} ↔ ${esc(item.payee_company_name)}规则建议:${esc(suggestions)} 科目待确认 ${fmtDate(item.effective_at)} ${fmtMoney(item.amount)} ${esc(item.currency)} 待确认科目 `; tbody.append(row); } function appendManualRecordReviewRow(record) { const tbody = $("#auditRows"); if (!tbody) return; const row = document.createElement("tr"); row.dataset.auditType = "手工"; row.dataset.recordKind = "manual-review"; row.dataset.recordId = record.id; row.dataset.expectedDecisionId = record.decision_id; row.dataset.company = record.company_name; row.dataset.submittedBy = record.submitted_by_username || ""; row.dataset.attachmentName = record.attachment_name || ""; row.dataset.reasonText = record.reason || ""; row.dataset.summary = record.summary || ""; row.dataset.amount = String(record.amount || ""); row.dataset.currency = record.currency || ""; row.innerHTML = ` ${esc(record.company_name)} · MR-${record.id}${esc(record.direction === "outgoing" ? "付款" : "收款")} ${fmtMoney(record.amount)} ${esc(record.currency)} · ${esc(record.counterparty_company_name)} · ${esc(b44.subjectLabels[record.requested_subject] || "")} 手工记录 ${fmtDate(record.occurred_at)} ${esc(record.summary || record.reason || "待补充说明")} 待复核 `; tbody.append(row); } function fillAuditEvidence(evidence, row, cells) { const heading = document.createElement("strong"); heading.textContent = cells[1].querySelector("strong")?.textContent || ""; evidence.append(heading); if (row.dataset.recordKind === "subject-review") { const meta = document.createElement("small"); meta.textContent = `${fmtDate(row.dataset.effectiveAt)} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim(); evidence.append(meta); if (row.dataset.summary) { const summary = document.createElement("small"); summary.textContent = `摘要:${row.dataset.summary}`; evidence.append(summary); } const suggestions = JSON.parse(row.dataset.suggestions || "[]"); const list = document.createElement("ul"); list.className = "candidate-list"; if (!suggestions.length) { const item = document.createElement("li"); item.textContent = "无规则建议(需退回或转异常,不能猜测科目)"; list.append(item); } else { suggestions.forEach((suggestion) => { const item = document.createElement("li"); item.textContent = `规则建议(只读):${suggestion.suggested_company_name || ""} · ${suggestion.suggested_subject_label || suggestion.suggested_subject_code || ""}`; list.append(item); }); } evidence.append(list); return; } if (row.dataset.recordKind === "manual-review") { const meta = document.createElement("small"); meta.textContent = `提交人 ${row.dataset.submittedBy || "—"} · 附件 ${row.dataset.attachmentName || "无"}`; evidence.append(meta); const summary = document.createElement("small"); summary.textContent = `摘要:${row.dataset.summary || "—"} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim(); evidence.append(summary); const reason = document.createElement("p"); reason.className = "reason-full"; reason.textContent = row.dataset.reasonText || cells[1].querySelector("small")?.textContent || ""; evidence.append(reason); return; } const detail = document.createElement("small"); detail.textContent = cells[1].querySelector("small")?.textContent || ""; const source = document.createElement("small"); source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`; evidence.append(detail, source); } async function loadAdminAuditQueue() { try { const [subjects, records] = await Promise.all([ apiJson(`/api/admin/subject-reviews?${balanceQuery()}&limit=100`), apiJson("/api/admin/manual-records?limit=100"), ]); $$("#auditRows [data-record-kind='subject-review'], #auditRows [data-record-kind='manual-review']").forEach((row) => row.remove()); (subjects.items || []).forEach(appendSubjectReviewRow); (records.records || []).filter((record) => record.state === "pending").forEach(appendManualRecordReviewRow); updateAuditCounts(); } catch { /* the demo audit table stays visible if the queue cannot load */ } } // --- Company portal: balances ------------------------------------------------ async function loadCompanyBalances() { const summaryLine = $("#companyBalanceGroups") || $("#companyBalanceLine"); const counterpartyList = $("#companyCounterparties"); const alertBox = $("#companyUnresolvedAlert"); if (!summaryLine && !counterpartyList) return; if (summaryLine) summaryLine.innerHTML = regionSkeleton(1); if (counterpartyList) counterpartyList.innerHTML = regionSkeleton(2); try { const data = await apiJson(`/api/company/intercompany/balances?${balanceQuery()}`); $("#companyBalancePeriod").textContent = `统计口径 ${fmtDate(data.window.from)}—${fmtDate(data.window.cutoff)} · 期初不可用,仅显示期间净变动`; if (summaryLine) { const items = data.items || []; if (!items.length) { summaryLine.innerHTML = sixLineHtml({ currency: "", debit: 0, credit: 0, signed: 0, resultLabel: "期间净变动", unresolved: { gross_amount: "0", count: 0 }, cutoff: data.window.cutoff, }); } else { summaryLine.innerHTML = items.map((item) => sixLineHtml({ currency: item.currency, debit: item.period.debit, credit: item.period.credit, signed: item.result.signed_amount, resultLabel: item.result.label, unresolved: item.unresolved, cutoff: item.window?.cutoff || data.window.cutoff, })).join(""); } } const unresolvedCount = (data.items || []).reduce((sum, item) => sum + (item.unresolved?.count || 0), 0); if (alertBox) { if (unresolvedCount) { alertBox.hidden = false; const parts = (data.items || []) .filter((item) => item.unresolved?.count) .map((item) => `${item.currency} ${fmtAbsMoney(item.unresolved.gross_amount)}`); $("#companyUnresolvedText").textContent = `有 ${unresolvedCount} 笔往来未确认,合计 ${parts.join("、")},不影响已确认余额`; } else { alertBox.hidden = true; } } renderCompanyCounterparties(counterpartyList, data.counterparties || [], data.window.cutoff); } catch (error) { if (summaryLine) regionError(summaryLine, error.message, loadCompanyBalances); if (counterpartyList) counterpartyList.innerHTML = ""; } } function renderCompanyCounterparties(container, counterparties, cutoff) { if (!container) return; if (!counterparties.length) { regionEmpty(container, "暂无对方公司往来", "确认往来事件后,对方公司明细将在此展示"); return; } container.replaceChildren(...counterparties.map((row) => { const article = document.createElement("article"); article.className = "company-row is-balance-counterparty"; article.tabIndex = 0; article.dataset.counterpartyId = row.counterparty_company_id; article.setAttribute("role", "button"); article.setAttribute("aria-label", `查看与 ${row.counterparty_company_name} 的往来明细`); const direction = resultDirection(row.result.direction); const unresolved = unresolvedText(row.unresolved); article.innerHTML = ` ${esc(row.counterparty_company_name.slice(0, 1))}
${esc(row.counterparty_company_name)}${esc(row.currency)} · ${unresolved.active ? `未决 ${fmtAbsMoney(row.unresolved.gross_amount)} · ${row.unresolved.count} 笔` : "未决 0.00"}
${direction.label} ${amountWithCurrency(row.result.signed_amount, row.currency)}截止 ${fmtDate(cutoff)}
`; const open = () => { drawer.open(article); renderCompanyPair(row.counterparty_company_id, row.counterparty_company_name); }; article.addEventListener("click", open); article.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); } }); return article; })); } async function renderCompanyPair(counterpartyId, counterpartyName) { drawer.open(); drawer.renderLayer(`${counterpartyName} · 往来明细`, [{ label: homeCrumb(), action: () => drawer.close() }], regionSkeleton(4)); try { const data = await apiJson(`/api/company/intercompany/pairs/${counterpartyId}?${balanceQuery()}`); const own = data.companies.a; const lines = (data.items || []).map((item) => sixLineHtml({ currency: item.currency, debit: item.a.period.debit, credit: item.a.period.credit, signed: item.a.result.signed_amount, resultLabel: item.a.result.label, unresolved: item.unresolved, cutoff: data.window.cutoff, conservationNote: "双方守恒已校验", })).join(""); const body = ` ${lines}
${eventTableHead()}
`; drawer.renderLayer( `${own.name} ↔ ${counterpartyName}`, [{ label: homeCrumb(), action: () => drawer.close() }, { label: `${own.name} ↔ ${counterpartyName}` }], body, (root) => loadCompanyEvents(root, counterpartyId), { replaceTop: true }, ); } catch (error) { $("#drawerBody").innerHTML = `
余额计算暂时不可用

${esc(error.message)}

`; $("#drawerBody [data-retry]")?.addEventListener("click", () => renderCompanyPair(counterpartyId, counterpartyName)); } } async function loadCompanyEvents(root, counterpartyId) { const tbody = $("#companyEventsTable tbody", root); if (!tbody) return; try { const data = await apiJson(`/api/company/intercompany/events?${balanceQuery()}&limit=100`); const ownCompanyId = state.me?.company_id; const filtered = (data.items || []).filter((event) => event.counterparty_company_id === counterpartyId); if (!filtered.length) { tbody.innerHTML = '
该区间无往来事件

当前统计区间内没有与对方公司的逐笔事件

'; return; } tbody.innerHTML = filtered.map((event) => eventRowHtml(event, ownCompanyId)).join(""); bindEventRows(root, "companyEventsTable", ownCompanyId); } catch (error) { tbody.innerHTML = `
逐笔事件加载失败

${esc(error.message)}

`; } } // --- B-44 wiring ------------------------------------------------------------- function initBalanceQueries() { $$("[data-balance-form]").forEach((form) => { form.addEventListener("submit", (event) => { event.preventDefault(); const from = $('[name="from"]', form).value; const cutoff = $('[name="cutoff"]', form).value; const currency = $('[name="currency"]', form).value || ""; if (from > cutoff) { showToast("日期范围无效", "统计起始不能晚于统计截止"); return; } b44.from = from; b44.cutoff = cutoff; b44.currency = currency; loadAdminBalances($("#balanceLedgers")); showToast("查询结果已更新", `${from} 至 ${cutoff} · 截止日口径`); }); }); // Dashboard quick-pair form: resolve company names to ids and open the pair. $$("[data-pair-form]").forEach((form) => form.addEventListener("submit", (event) => { event.preventDefault(); const from = $('[name="from"]', form).value; const to = $('[name="to"]', form).value; if (from === to) { showToast("请选择两个不同的公司", "同公司账户调拨不进入公司间往来查询"); return; } const fromId = [...b44.companyNames.entries()].find(([, name]) => name === from)?.[0]; const toId = [...b44.companyNames.entries()].find(([, name]) => name === to)?.[0]; if (!fromId || !toId) { showToast("无法解析公司主档", "请先在「公司与账号」中建立公司"); return; } renderAdminPair(fromId, toId, from); })); // Dashboard quick-pair buttons and static pair links resolve to the drawer. $$("[data-pair-link]").forEach((button) => button.addEventListener("click", () => { const [fromName, toName] = String(button.dataset.pairLink || "").split("|"); const fromId = [...b44.companyNames.entries()].find(([, name]) => name === fromName)?.[0]; const toId = [...b44.companyNames.entries()].find(([, name]) => name === toName)?.[0]; if (!fromId || !toId) { showToast("无法解析演示公司", "正式数据请先在「公司与账号」中建立公司主档"); return; } renderAdminPair(fromId, toId, fromName); })); } async function loadCompanyOptions() { try { const data = await apiJson("/api/admin/companies"); (data.companies || []).forEach((company) => b44.companyNames.set(company.id, company.name)); } catch { /* best-effort name resolution for dashboard pair links */ } } async function loadCompanyDirectoryOptions() { try { const data = await apiJson("/api/company/companies"); (data.companies || []).forEach((company) => b44.companyNames.set(company.id, company.name)); const select = $("#manualEntryForm [name='counterparty']"); if (select) { const kept = [...select.options].filter((option) => option.dataset.keep); select.replaceChildren(...kept); (data.companies || []).forEach((company) => select.add(new Option(company.name, company.id))); } } catch { /* the manual form keeps the free-text counterparty as fallback */ } } async function submitCompanyManualRecord(form) { const data = new FormData(form); const sourceOption = $('[name="sourceAccount"] option:checked', form); const counterpartyOption = $('[name="counterparty"] option:checked', form); const payload = { counterparty_company_id: Number(counterpartyOption?.value || 0) || null, occurred_at: `${String(data.get("transactionDate"))}T12:00:00`, direction: String(data.get("direction")) === "付款" ? "outgoing" : "incoming", amount: String(data.get("amount")), currency: "CNY", funding_source: sourceOption?.dataset.accountId ? "approved_bank_account" : "other", bank_account_id: sourceOption?.dataset.accountId ? Number(sourceOption.dataset.accountId) : null, requested_subject: b44.subjectCodes[String(data.get("subject"))] || "other_receivable", summary: String(data.get("summary") || "").trim(), reason: String(data.get("remark") || "").trim(), request_key: `mr-${Date.now().toString(36)}`, }; const evidenceFile = data.get("evidence"); if (evidenceFile instanceof File && evidenceFile.name) { payload.evidence = { attachment_name: evidenceFile.name }; } if (!payload.counterparty_company_id) { showToast("请选择对方公司", "手工记录对方必须是集团内部公司"); return; } if (!payload.amount || Number(payload.amount) <= 0) { showToast("金额无效", "金额必须大于零"); return; } try { const result = await apiJson("/api/company/manual-records", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); form.reset(); showToast("手工记录已提交", "总账复核前不会纳入公司间往来计算"); await loadCompanyManualRecords(); return result; } catch (error) { showToast("手工记录提交失败", error.message); } } async function loadCompanyManualRecords() { const tbody = $("#manualRecordRows"); if (!tbody) return; try { const data = await apiJson("/api/company/manual-records?limit=100"); $$('[data-stored-record]', tbody).forEach((row) => row.remove()); (data.records || []).forEach((record) => appendCompanyManualRow(record)); const pending = (data.records || []).filter((record) => record.state === "pending").length; const status = $("#manualPendingStatus"); if (status) status.textContent = `${pending} 笔待总账复核`; } catch { /* keep the demo rows when the API is unavailable */ } } function appendCompanyManualRow(record) { const tbody = $("#manualRecordRows"); if (!tbody) return; const row = document.createElement("tr"); row.dataset.storedRecord = record.id; const identity = document.createElement("td"); const id = document.createElement("strong"); id.textContent = `MR-${record.id}`; const date = document.createElement("small"); date.textContent = fmtDate(record.occurred_at); identity.append(id, date); const direction = document.createElement("td"); const directionName = document.createElement("strong"); directionName.textContent = record.direction === "outgoing" ? "付款" : "收款"; const subject = document.createElement("small"); subject.textContent = b44.subjectLabels[record.requested_subject] || record.requested_subject; direction.append(directionName, subject); const source = document.createElement("td"); source.textContent = { approved_bank_account: "已批准账户", personal_transit: "个人过账", other: "其他来源" }[record.funding_source] || record.funding_source; const counterparty = document.createElement("td"); const counterpartyName = document.createElement("strong"); counterpartyName.textContent = record.counterparty_company_name; counterparty.append(counterpartyName); const summary = document.createElement("td"); summary.textContent = record.summary || record.reason || "—"; const amount = document.createElement("td"); amount.className = "number"; amount.textContent = fmtMoney(record.amount); const statusCell = document.createElement("td"); const stateMap = { pending: ["warning", "待总账复核"], approved: ["success", "已确认"], returned: ["danger", "已退回"], exception: ["neutral", "异常待处理"], reversed: ["neutral", "已冲销"] }; const [className, label] = stateMap[record.state] || ["neutral", record.state]; const badge = document.createElement("span"); badge.className = `status ${className}`; badge.textContent = label; statusCell.append(badge); row.append(identity, direction, source, counterparty, summary, amount, statusCell); tbody.append(row); } function renderCompanyManualRecords() { const tbody = $("#manualRecordRows"); if (!tbody) return; $$('[data-stored-record]', tbody).forEach((row) => row.remove()); 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 identity = document.createElement("td"); const id = document.createElement("strong"); id.textContent = record.id; const date = document.createElement("small"); date.textContent = record.transactionDate; identity.append(id, date); const direction = document.createElement("td"); const directionName = document.createElement("strong"); directionName.textContent = record.direction; const subject = document.createElement("small"); subject.textContent = record.subject; direction.append(directionName, subject); const source = document.createElement("td"); source.textContent = record.sourceAccount; const counterparty = document.createElement("td"); const counterpartyName = document.createElement("strong"); counterpartyName.textContent = record.counterparty; const counterpartyType = document.createElement("small"); counterpartyType.textContent = record.counterpartyType; counterparty.append(counterpartyName, counterpartyType); const summary = document.createElement("td"); summary.textContent = record.summary; const amount = document.createElement("td"); amount.className = "number"; amount.textContent = formatCurrency(record.amount); const statusCell = document.createElement("td"); const status = recordStatus(record.status); const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label; statusCell.append(badge); row.append(identity, direction, source, counterparty, summary, amount, statusCell); tbody.append(row); }); const pending = records.filter((record) => record.status === "待总账复核").length + 1; if ($("#manualPendingStatus")) $("#manualPendingStatus").textContent = `${pending} 笔待总账复核`; } 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 renderCompanyAccounts(accounts) { const directory = $("#accountDirectory"); if (directory) { directory.replaceChildren(...accounts.map((account) => { const article = document.createElement("article"); const header = document.createElement("header"); const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = account.bank_name.slice(0, 1); const identity = document.createElement("div"); const name = document.createElement("strong"); name.textContent = account.bank_name; const meta = document.createElement("small"); meta.textContent = `${account.account_type} · 尾号 ${accountTail(account.account_number_masked)}`; identity.append(name, meta); const status = recordStatus(accountStatusLabel(account.status)); const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label; header.append(mark, identity, badge); const details = document.createElement("dl"); const rows = [ ["申请启用", account.effective_from || "待审核确定"], ["流水覆盖", account.usable ? "尚未上传" : "不参与计算"], ["提交时间", String(account.created_at || "").slice(0, 10) || "—"], ]; if (account.status === "returned" && account.review_reason) rows.push(["退回原因", account.review_reason]); if (account.status === "disabled" && account.effective_to) rows.push(["停用日期", account.effective_to]); rows.forEach(([term, value]) => { const wrapper = document.createElement("div"); const dt = document.createElement("dt"); dt.textContent = term; const dd = document.createElement("dd"); dd.textContent = value; wrapper.append(dt, dd); details.append(wrapper); }); article.append(header, details); return article; })); } fillAccountSelects(accounts); } async function loadCompanyAccounts() { 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) return; const result = await response.json().catch(() => null); if (result?.accounts) renderCompanyAccounts(result.accounts); } function appendAdminReviewRow(record, kind) { const tbody = $("#auditRows"); if (!tbody) return; const isAccount = kind === "account"; // Account rows come from the server (full number visible only in this // authorized admin view); manual records are still browser-local demo data. 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"; const riskCell = document.createElement("td"); const risk = document.createElement("span"); risk.className = "task-level warning"; risk.textContent = "中"; riskCell.append(risk); const identityCell = document.createElement("td"); const identity = document.createElement("strong"); const detail = document.createElement("small"); const typeCell = document.createElement("td"); const periodCell = document.createElement("td"); const impactCell = document.createElement("td"); if (isAccount) { identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`; detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`; typeCell.textContent = "账户登记"; periodCell.textContent = record.effective_from || "待审核确定"; impactCell.textContent = "账户识别与流水上传"; } else { identity.textContent = `${record.company} · ${record.id}`; detail.textContent = `${record.direction} ${formatCurrency(record.amount)} 元 · ${record.counterparty} · ${record.subject}`; typeCell.textContent = "手工记录"; periodCell.textContent = record.transactionDate; impactCell.textContent = `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`; } identityCell.append(identity, detail); const statusCell = document.createElement("td"); const status = recordStatus(statusLabel); const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label; statusCell.append(badge); const actionCell = document.createElement("td"); const action = document.createElement("button"); action.className = "text-button"; action.dataset.auditAction = ""; if (isAccount) row.dataset.accountStatus = record.status; action.textContent = row.dataset.resolved ? (isAccount && record.status === "active" ? "管理" : "查看记录") : "复核"; if (row.dataset.resolved) { const decisionLabels = { active: "复核通过并启用账户", returned: "退回公司修改", disabled: "停用并驳回" }; const decisionLabel = isAccount ? decisionLabels[record.status] || statusLabel : record.decision || record.status; const reasonText = (isAccount ? record.review_reason : record.reviewReason) || "已留痕"; action.dataset.record = `${decisionLabel} · ${reasonText}`; action.dataset.decision = decisionLabel; action.dataset.reason = reasonText; action.dataset.processedAt = (isAccount ? record.reviewed_at : record.reviewedAt) || "时间未记录"; } actionCell.append(action); 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) return; const result = await response.json().catch(() => null); (result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account")); updateAuditCounts(); } 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; button.textContent = `${button.dataset.label} ${count}`; }); const badge = $('.nav-item[data-view="audit"] b'); if (badge) badge.textContent = 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; tbody.replaceChildren(...companies.map((company) => { const row = document.createElement("tr"); const nameCell = document.createElement("td"); const name = document.createElement("strong"); name.textContent = company.name; const code = document.createElement("small"); code.textContent = `COMP-${String(company.id).padStart(3, "0")}`; nameCell.append(name, code); const credit = document.createElement("td"); credit.textContent = company.credit_code || "待补充"; const accounts = document.createElement("td"); accounts.textContent = `${company.account_count ?? 0} 个`; const usernames = document.createElement("td"); 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, credit, accounts, usernames, cashier, statusCell, actionCell); return row; })); } 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 }); setSelectOptions($('#reminderForm [name="company"]'), names, { keepFirst: true }); setSelectOptions($('#openingDialog [name="from"]'), names); setSelectOptions($('#openingDialog [name="to"]'), names); } async function loadAdminCompanies() { const response = await fetch("/api/admin/companies").catch(() => null); if (!response?.ok) return; const result = await response.json().catch(() => null); const companies = result?.companies || []; renderAdminCompanyTable(companies); fillCompanySelects(companies.map((company) => company.name)); } function initAdmin() { renderStoredAdminReviews(); updateAuditCounts(); loadAdminCompanies(); initBalanceQueries(); loadCompanyOptions().then(() => { loadAdminBalances($("#balanceLedgers")); }); loadAdminAuditQueue(); const companySearch = $('[data-filter-target="companyLedgers"]'); companySearch?.addEventListener("input", () => { const query = companySearch.value.trim().toLowerCase(); $$("#companyLedgers .company-ledger").forEach((item) => { item.hidden = query ? !item.textContent.toLowerCase().includes(query) : false; }); }); 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("is-active", active); item.setAttribute("aria-pressed", String(active)); }); filterAuditRows(); })); $("#auditCompany")?.addEventListener("change", filterAuditRows); const TRIAGE_DECISIONS = ["确认并纳入计算", "退回公司补充材料", "转为异常"]; const auditDialog = $("#auditDialog"); const syncExceptionNote = () => { const note = $("#auditExceptionNote"); const decision = $('#auditForm [name="decision"]'); if (note) note.hidden = !String(decision?.value || "").includes("转为异常"); }; $$('[data-close-audit]').forEach((button) => button.addEventListener("click", () => auditDialog.close())); $("#auditForm [name='decision']")?.addEventListener("change", syncExceptionNote); // Delegated: account review rows arrive asynchronously from the API. $("#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 form = $("#auditForm"); const decision = $('[name="decision"]', form); const reason = $('[name="reason"]', form); const submit = $('button[type="submit"]', form); form.reset(); const isActiveAccount = row.dataset.recordKind === "account" && row.dataset.accountStatus === "active"; const decisions = row.dataset.recordKind === "account" ? (isActiveAccount ? ["停用并驳回"] : ["复核通过并启用账户", "退回公司修改", "停用并驳回"]) : (row.dataset.recordKind === "subject-review" || row.dataset.recordKind === "manual-review") ? TRIAGE_DECISIONS : TRIAGE_DECISIONS; decision.replaceChildren(new Option("请选择", ""), ...decisions.map((item) => new Option(item, item))); decision.disabled = false; reason.disabled = false; submit.hidden = false; $("#auditDialogTitle").textContent = `${cells[2].innerText.trim()} · ${cells[1].querySelector("strong").textContent}`; $("#auditDialogMeta").textContent = `${cells[3].innerText.trim()} · 影响 ${cells[4].innerText.trim()}`; const evidence = $("#auditEvidence"); evidence.replaceChildren(); fillAuditEvidence(evidence, row, cells); syncExceptionNote(); if (button.dataset.record && !isActiveAccount) { decision.value = button.dataset.decision; reason.value = button.dataset.reason; decision.disabled = true; reason.disabled = true; submit.hidden = true; $("#auditDialogMeta").textContent = `已处理 · ${button.dataset.processedAt} · 系统管理员`; const record = document.createElement("small"); record.textContent = `处理记录:${button.dataset.record}`; evidence.append(record); syncExceptionNote(); } auditDialog.showModal(); }); $("#auditForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); const row = state.auditRow; const decision = String(data.get("decision")); const reason = String(data.get("reason")); // B-44 subject review: confirm uses the rule suggestion; return/exception park the event. if (row.dataset.recordKind === "subject-review") { const actionMap = { "确认并纳入计算": "confirm", "退回公司补充材料": "return", "转为异常": "exception", }; const action = actionMap[decision]; if (!action) { showToast("请选择处理决定", "科目审核仅支持确认、退回或转异常"); return; } const suggestions = JSON.parse(row.dataset.suggestions || "[]"); const suggested = suggestions[0]; if (action === "confirm" && !suggested?.suggested_subject_code) { showToast("没有可入账的规则建议", "请退回公司补充材料或转为异常,不要猜测科目"); return; } const body = { perspective_company_id: Number(suggested?.suggested_perspective_company_id || row.dataset.perspectiveId), reason, expected_revision: Number(row.dataset.expectedRevision), request_key: `subj-${row.dataset.ledgerEventId}-${Date.now().toString(36)}`, }; if (action === "confirm") { body.subject_code = suggested.suggested_subject_code; } else { body.action = action; } const response = await apiJson(`/api/admin/intercompany/events/${row.dataset.ledgerEventId}/subject-decisions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }).catch(() => null); if (!response) { showToast("科目处理失败", "请刷新后重试"); return; } row.remove(); updateAuditCounts(); auditDialog.close(); event.currentTarget.reset(); syncExceptionNote(); showToast( action === "confirm" ? "科目已确认并纳入计算" : action === "return" ? "已退回公司补充材料" : "已转为异常", action === "confirm" ? `确认科目:${b44.subjectLabels[suggested.suggested_subject_code]} · 已写入修订链` : "当前记录暂不纳入余额计算", ); await loadAdminAuditQueue(); return; } // B-44 manual record review: approve / return / exception. if (row.dataset.recordKind === "manual-review") { const actionMap = { "确认并纳入计算": "approve_new", "退回公司补充材料": "return", "转为异常": "exception", "转为异常待后续处理": "exception", }; const action = actionMap[decision]; if (!action) { showToast("请选择处理决定", "人工记录仅支持确认、退回或转异常"); return; } const response = await apiJson(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, reason, expected_decision_id: Number(row.dataset.expectedDecisionId), request_key: `mr-dec-${row.dataset.recordId}-${Date.now().toString(36)}`, }), }).catch(() => null); if (!response) { showToast("审核结果提交失败", "请刷新后重试"); return; } const status = $("[data-audit-action]", row); if (status) status.textContent = "查看记录"; row.dataset.resolved = "true"; const statusCell = row.children[5]; statusCell.innerHTML = `${action === "approve_new" ? "已确认" : action === "return" ? "已退回" : "异常待处理"}`; updateAuditCounts(); auditDialog.close(); event.currentTarget.reset(); showToast("审核结果已记录", action === "approve_new" ? "已批准的手工记录将幂等纳入往来计算" : "当前记录不参与往来计算"); await loadAdminAuditQueue(); return; } const approved = 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 || "请稍后重试"); 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"; const action = $("[data-audit-action]", row); if (reviewedAccount) { row.dataset.accountStatus = reviewedAccount.status; action.textContent = reviewedAccount.status === "active" ? "管理" : "查看记录"; } else { action.textContent = "查看记录"; } action.dataset.record = `${decision} · ${data.get("reason")}`; action.dataset.decision = decision; action.dataset.reason = data.get("reason"); action.dataset.processedAt = new Date().toLocaleString("zh-CN", { hour12: false }); updateAuditCounts(); auditDialog.close(); event.currentTarget.reset(); showToast("审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算"); }); const dialog = $("#companyDialog"); $("#openCompanyDialog")?.addEventListener("click", () => dialog.showModal()); $("#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 || "请稍后重试"); return; } const accountCreated = Boolean(result.username); dialog.close(); event.currentTarget.reset(); await loadAdminCompanies(); showToast( accountCreated ? "公司与账号已创建" : "公司已创建", accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号", ); }); $("#systemSettings")?.addEventListener("submit", (event) => { event.preventDefault(); showToast("系统计算口径已保存", "正式系统将记录修改前后值与操作人"); }); $("#runClosingCheck")?.addEventListener("click", () => { const unresolved = $$(".audit-table tbody tr").filter((row) => row.dataset.resolved !== "true" && !$(".status.success", row)).length; if (unresolved) { showToast("结账检查未通过", `仍有 ${unresolved} 项审核事项,已打开审核中心`); showView("audit"); } else { $$("#closingPanel .is-blocked").forEach((item) => { item.classList.remove("is-blocked"); $("use", item).setAttribute("href", "icons.svg#circle-check"); $("small", item).textContent = "检查已通过"; }); $("#closingDescription").textContent = "2026 年 7 月 · 全部前置检查已通过"; $("#closingStatus").className = "status success"; $("#closingStatus").textContent = "可结账"; $("#executeClosing").disabled = false; $("#executeClosing").removeAttribute("title"); showToast("结账检查通过", "执行结账按钮已解锁"); } }); const closingDialog = $("#closingDialog"); $("#executeClosing")?.addEventListener("click", () => closingDialog.showModal()); $$('[data-close-closing]').forEach((button) => button.addEventListener("click", () => closingDialog.close())); $("#closingForm")?.addEventListener("submit", (event) => { event.preventDefault(); closingDialog.close(); $("#closingDescription").textContent = "2026 年 7 月 · 已完成集团结账"; $("#closingStatus").className = "status success"; $("#closingStatus").textContent = "已结账"; $("#closingHistory").textContent = `${new Date().toLocaleString("zh-CN", { hour12: false })} · 系统管理员执行 2026 年 7 月结账 · 已写入审计记录`; $("#runClosingCheck").disabled = true; $("#executeClosing").disabled = true; $("#executeClosing").textContent = "7 月已结账"; showToast("2026 年 7 月已完成结账", "本期结果已锁定,后续补录将进入重开流程"); }); const openingDialog = $("#openingDialog"); $("#openOpeningDialog")?.addEventListener("click", () => openingDialog.showModal()); $$('[data-close-opening]').forEach((button) => button.addEventListener("click", () => openingDialog.close())); $("#openingForm")?.addEventListener("submit", (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); if (data.get("from") === data.get("to")) { showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初"); return; } const row = document.createElement("tr"); [data.get("from"), data.get("to"), data.get("subject"), data.get("direction")].forEach((value) => { const cell = document.createElement("td"); cell.textContent = value; row.append(cell); }); const amount = document.createElement("td"); amount.className = "number"; amount.textContent = Number(data.get("amount")).toLocaleString("zh-CN", {minimumFractionDigits:2}); row.append(amount); const date = document.createElement("td"); date.textContent = data.get("effectiveDate"); row.append(date); const status = document.createElement("td"); status.innerHTML = '待复核'; row.append(status); $("#openingRows").append(row); openingDialog.close(); event.currentTarget.reset(); showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人"); }); $("#reminderForm")?.addEventListener("submit", (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); const article = document.createElement("article"); article.innerHTML = ''; const content = document.createElement("span"); const heading = document.createElement("strong"); heading.textContent = `${data.get("company")} · ${data.get("message")}`; const meta = document.createElement("small"); meta.textContent = `手动提醒 · 截止 ${data.get("dueDate")} · 刚刚`; content.append(heading, meta); const status = document.createElement("em"); status.className = "status warning"; status.textContent = "未读"; article.append(content, status); $("#adminReminderList").prepend(article); event.currentTarget.reset(); showToast("提醒已发送", "对方将在公司业务端收到站内通知"); }); } 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("日期范围无效", "开始日期不能晚于结束日期"); return; } let count = 0; $$("tbody tr", table).forEach((row) => { const rowDate = $("td", row).textContent.trim().replaceAll(".", "-"); const matchesCompany = company === "全部公司" || row.dataset.company === company; const matchesBank = bank === "全部银行" || row.dataset.bank === bank; const matchesAccount = account === "全部账户" || 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; 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()); return [cells[0], portal === "company" ? "A公司" : cells[1].split(" ")[0], portal === "company" ? cells[1] : cells[1], ...cells.slice(2), `IMP-DEMO-${String(index + 1).padStart(3, "0")}`, `Sheet1!R${index + 8}`]; }); 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" ? "集团" : "A公司"}银行流水_202607.csv`; link.click(); URL.revokeObjectURL(link.href); showToast("导出已生成", `共 ${rows.length} 笔,已保留银行标识与源行定位`); } function initFlowTools() { $("#applyFlowFilters")?.addEventListener("click", filterFlows); $("#exportFlows")?.addEventListener("click", exportFlows); } 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 文件"); 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("is-exception", !parsed); $("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert"); $("#parseTitle").textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板"; const pendingCount = sheets.filter((s) => s.outcome === "parsed" && s.review_status === "pending").length; const exceptionCount = sheets.filter((s) => s.outcome === "exception").length; const ignoredCount = sheets.filter((s) => s.outcome === "ignored" || s.review_status === "ignored").length; let summary; if (opaque) { summary = "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。"; } else if (sheets.length) { summary = `${sheets.length} 个工作表:${pendingCount} 个待确认${exceptionCount ? `、${exceptionCount} 个异常` : ""}${ignoredCount ? `、${ignoredCount} 个忽略` : ""}。解析成功不等于业务确认。`; } else { summary = `${result.message || ""} 系统不会猜测模板或自动入账。`; } $("#parseSummary").textContent = summary; panel.hidden = false; renderSheetList(sheets, result.batch_id); const button = $("#parseButton"); button.dataset.stage = "confirm"; if (!parsed) { button.querySelector("span").textContent = "关闭"; } else if (opaque) { button.querySelector("span").textContent = "完成"; } else if (sheets.length) { button.querySelector("span").textContent = pendingCount ? `确认全部(${pendingCount} 个待确认)` : "完成"; } else { button.querySelector("span").textContent = "完成"; } } function sheetStatusMeta(sheet) { if (sheet.review_status === "confirmed") return { className: "success", label: "已确认" }; if (sheet.review_status === "ignored") return { className: "neutral", label: "已忽略" }; if (sheet.outcome === "exception") return { className: "danger", label: "异常待处理" }; if (sheet.outcome === "ignored") return { className: "neutral", label: "空表忽略" }; return { className: "warning", label: "待确认" }; } function renderSheetList(sheets, batchId) { const wrap = $("#sheetReview"); const list = $("#sheetList"); if (!wrap || !list || !sheets.length) { if (wrap) wrap.hidden = true; return; } wrap.hidden = false; list.replaceChildren(...sheets.map((sheet) => buildSheetItem(sheet, batchId))); } function buildSheetItem(sheet, batchId) { const item = document.createElement("article"); item.className = "sheet-item"; if (sheet.review_status === "pending") item.classList.add("is-pending"); const meta = sheetStatusMeta(sheet); const head = document.createElement("div"); head.className = "sheet-item-head"; const name = document.createElement("strong"); name.textContent = sheet.sheet_name; const badge = document.createElement("em"); badge.className = `status ${meta.className}`; badge.textContent = meta.label; head.append(name, badge); const details = document.createElement("p"); details.className = "sheet-item-meta"; if (sheet.outcome === "parsed" && sheet.bank) { const period = sheet.period_start ? ` · ${sheet.period_start}—${sheet.period_end}` : ""; details.textContent = `${sheet.bank} · ${sheet.transactions} 条明细${period}`; } else if (sheet.message) { details.textContent = sheet.message; } else { details.textContent = "空工作表。"; } if (sheet.review_reason) { details.textContent += ` · 原因:${sheet.review_reason}`; } const body = document.createElement("div"); body.append(head, details); const actions = document.createElement("div"); actions.className = "sheet-item-actions"; if (sheet.review_status === "pending") { if (sheet.outcome === "parsed") { const confirmButton = document.createElement("button"); confirmButton.type = "button"; confirmButton.className = "text-button"; confirmButton.textContent = "确认"; confirmButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "confirm")); actions.append(confirmButton); } const ignoreButton = document.createElement("button"); ignoreButton.type = "button"; ignoreButton.className = "text-button"; ignoreButton.textContent = "忽略"; ignoreButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "ignore")); actions.append(ignoreButton); } item.append(body, actions); return item; } async function sheetReviewAction(batchId, sheetName, decision) { const payload = { sheets: [sheetName] }; if (decision === "ignore") { const reason = (window.prompt("请填写忽略原因(必填):", "") || "").trim(); if (!reason) { showToast("忽略未提交", "必须填写忽略原因"); return; } payload.reason = reason; } const response = await fetch(`/api/batches/${batchId}/${decision}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("操作失败", result?.message || "请稍后重试"); return; } showToast(decision === "confirm" ? "工作表已确认" : "工作表已忽略", `${sheetName}`); await refreshAfterSheetAction(result, batchId); } async function refreshAfterSheetAction(result, batchId) { if (Array.isArray(result.sheets)) renderSheetList(result.sheets, batchId); await loadImportBatches(); const button = $("#parseButton"); if (!button) return; const pending = (result.sheets || []).filter((s) => s.outcome === "parsed" && s.review_status === "pending").length; if (pending === 0 && (result.sheets || []).length) { button.querySelector("span").textContent = "完成"; button.dataset.stage = "done"; } else { button.querySelector("span").textContent = `确认全部(${pending} 个待确认)`; } } async function confirmImport() { const result = state.parseResult; const batchId = result?.batch_id; const sheets = Array.isArray(result?.sheets) ? result.sheets : []; const pending = sheets .filter((s) => s.outcome === "parsed" && s.review_status === "pending") .map((s) => s.sheet_name); if (!pending.length) { $("#uploadDialog").close(); await loadImportBatches(); showView("upload"); return; } const button = $("#parseButton"); button.disabled = true; button.querySelector("span").textContent = "正在确认..."; const response = await fetch(`/api/batches/${batchId}/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sheets: pending }), }).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const outcome = await response?.json().catch(() => ({})); if (!response || !response.ok) { button.disabled = false; button.querySelector("span").textContent = "确认失败,点击重试"; showToast("确认失败", outcome?.message || "请稍后重试"); return; } button.disabled = false; if (Array.isArray(outcome.sheets)) renderSheetList(outcome.sheets, batchId); await loadImportBatches(); $("#uploadDialog").close(); showView("upload"); showToast("流水已确认", `${outcome.updated.length} 个工作表已确认;未确认的工作表不参与计算`); } function submitImportException() { $("#uploadDialog").close(); showView("upload"); showToast("解析异常未入账", `${state.selectedFile?.name || "该文件"} 不会进入匹配与计算,请核对模板后重新导出`); } function renderBatchRow(batch) { const row = document.createElement("tr"); const idCell = document.createElement("td"); const id = document.createElement("strong"); id.textContent = `IMP-${String(batch.id).padStart(6, "0")}`; const file = document.createElement("small"); file.textContent = batch.original_filename || ""; idCell.append(id, file); const bank = document.createElement("td"); bank.textContent = batch.bank_name || "—"; const period = document.createElement("td"); period.textContent = batch.period_start && batch.period_end ? `${batch.period_start}—${batch.period_end}` : "—"; const count = document.createElement("td"); count.className = "number"; count.textContent = `${batch.confirmed_transactions ?? 0} 笔`; const coverage = document.createElement("td"); const coverageStatus = batch.status === "exception" ? { className: "danger", label: "未导入" } : batch.pending_sheets > 0 ? { className: "warning", label: `${batch.pending_sheets} 个待确认` } : batch.confirmed_sheets > 0 ? { className: "success", label: "已确认" } : { className: "neutral", label: "待处理" }; coverage.innerHTML = `${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} 个忽略`); parseState.textContent = statusParts.length ? statusParts.join("、") : "解析成功"; const time = document.createElement("td"); time.textContent = String(batch.created_at || "").slice(0, 16).replace("T", " "); row.append(idCell, bank, period, count, coverage, parseState, time); return row; } async function loadImportBatches() { const tbody = $("#importRows"); if (!tbody) return; const response = await fetch("/api/batches").catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); const batches = Array.isArray(result?.batches) ? result.batches : []; if (batches.length) tbody.replaceChildren(...batches.map(renderBatchRow)); } function initCompany() { loadCompanyManualRecords(); loadCompanyAccounts(); loadCompanyBalances(); loadCompanyDirectoryOptions(); loadImportBatches(); const uploadDialog = $("#uploadDialog"); $$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.showModal())); $$('[data-close-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.close())); uploadDialog?.addEventListener("close", resetUpload); $("#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("is-dragging"); })); ["dragleave", "drop"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove("is-dragging"); })); 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) { $("#uploadDialog").close(); showView("upload"); await loadImportBatches(); } else { submitImportException(); } return; } $("#parseButton").disabled = true; $("#parseButton span").textContent = "正在识别表头..."; await parseFile(); }); function finishReview(button, disposition) { const item = button.closest("article"); const type = item.dataset.reviewType; const selectedMatch = $('input[name="matchCandidate"]:checked', item)?.value; const selectedSubject = $("select", item)?.value; const detail = type === "match" ? (selectedMatch || "转为匹配异常") : selectedSubject; const history = $("#reviewHistory"); const record = document.createElement("p"); record.textContent = `${new Date().toLocaleString("zh-CN", { hour12: false })} · 牛女士 · ${disposition} · ${detail}`; history.append(record); history.hidden = false; item.remove(); if (type === "match") $("#matchPendingCount").textContent = "0 笔"; else $("#subjectPendingCount").textContent = "0 笔"; const remaining = $$("#reviewList > article").length; const badge = $('.nav-item[data-view="reconcile"] b'); if (badge) badge.textContent = remaining; $(`.cashier-tasks [data-task-type="${type}"]`)?.remove(); const workspaceRemaining = $$(".cashier-tasks > article").length; $("#workspacePendingStatus").textContent = `${workspaceRemaining} 项待处理`; const workspaceBadge = $('.nav-item[data-view="workspace"] b'); if (workspaceBadge) workspaceBadge.textContent = workspaceRemaining; showToast("处理结果已记录", "待办状态、操作人、时间和依据已同步更新"); } $$('[data-resolve]').forEach((button) => button.addEventListener("click", () => finishReview(button, "确认"))); $$('[data-reject]').forEach((button) => button.addEventListener("click", () => finishReview(button, "转异常"))); $("#markAllRead")?.addEventListener("click", () => { $$("#companyNotifications .is-unread").forEach((item) => { item.classList.remove("is-unread"); const status = $(".status", item); status.className = "status neutral"; status.textContent = "已读"; }); showToast("通知已全部标为已读"); }); $("#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 的文件"); return; } submitCompanyManualRecord(form); }); const accountDialog = $("#accountDialog"); $("#openAccountDialog")?.addEventListener("click", () => accountDialog.showModal()); $$('[data-close-account]').forEach((button) => button.addEventListener("click", () => accountDialog.close())); $("#accountForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); // The server binds the account to the session company and normalizes the // number; duplicates come back as 409. 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 || "请稍后重试"); return; } accountDialog.close(); event.currentTarget.reset(); await loadCompanyAccounts(); showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算"); }); } if (typeof document !== "undefined" && document.body) { if (portal === "entry") { initEntry(); } else { initAuthGuard().then((allowed) => { if (!allowed) return; initShell(); initFlowTools(); if (portal === "admin") { initAdmin(); } else { initCompany(); } }); } } if (typeof module !== "undefined" && module.exports) { module.exports = { fmtAbsMoney, fmtMoney, eventIsNegative, cycleTab, drawerEscAction, amountWithCurrency, resultDirection, accountCell, }; }