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

`; document.body.append(drawer); let lastTrigger = null; function closeDrawer({ restoreFocus = true } = {}) { drawer.classList.remove("is-open"); if (restoreFocus && lastTrigger) lastTrigger.focus(); } function openDetail(id, trigger) { const item = detailContent[id]; if (!item) return; lastTrigger = trigger; const tag = $("#detailTag", drawer); tag.className = `pill ${pillClass(item.tag[0])}`; tag.textContent = item.tag[1]; $("#detailTitle", drawer).textContent = item.title; $("#detailDesc", drawer).textContent = item.desc; const fields = $("#detailFields", drawer); fields.replaceChildren(...item.fields.flatMap(([label, value]) => { const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); dd.textContent = value; return [dt, dd]; })); const tip = $("#detailTip", drawer); tip.replaceChildren(); const tipHeading = document.createElement("strong"); tipHeading.textContent = "处理建议"; tip.append(tipHeading, document.createTextNode(item.tip)); const action = $("#detailAction", drawer); action.textContent = item.action[0]; action.onclick = () => { closeDrawer({ restoreFocus: false }); if (item.action[1] === "upload") $("[data-open-upload]")?.click(); else showView(item.action[1]); }; drawer.classList.add("is-open"); $("[data-close-detail]", drawer).focus(); } $$("[data-close-detail]", drawer).forEach((button) => button.addEventListener("click", () => closeDrawer())); drawer.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.stopPropagation(); closeDrawer(); } }); triggers.forEach((element) => { element.addEventListener("click", (event) => { const innerButton = event.target.closest("button"); if (innerButton && innerButton !== element) return; openDetail(element.dataset.detail, element); }); if (element.tagName !== "BUTTON") { element.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openDetail(element.dataset.detail, element); } }); } }); } function initEntry() { const form = $("#loginForm"); if (!form) return; if (!motionQuery.matches) { [ [$(".entry-form-wrap"), 0], [$(".entry-statement h1"), 60], [$(".entry-statement p"), 120], [$(".entry-figure"), 120], ].forEach(([element, delay]) => { if (!element) return; element.animate( [{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 300, delay, easing: "cubic-bezier(.16,1,.3,1)", fill: "both" }, ); }); } const roleInputs = $$('input[name="role"]', form); const username = $('input[name="username"]', form); const password = $('input[name="password"]', form); const action = $("#loginAction"); const errorBox = $("#loginError"); const changeSection = $("#changePassword"); let pendingRole = null; const submitButton = $('button[type="submit"]', form); function setLoading(loading) { submitButton.disabled = loading; submitButton.classList.toggle("is-loading", loading); $$("input, button", form).forEach((el) => { if (el !== submitButton) el.disabled = loading; }); if (loading) { action.textContent = "登录中…"; } else if (pendingRole) { action.textContent = "设置新密码并进入"; } else { updateRole(); } } function showError(message) { errorBox.textContent = message; errorBox.hidden = false; } function updateRole() { const role = $('input[name="role"]:checked', form).value; action.textContent = role === "admin" ? "进入管理端" : "进入公司业务端"; } roleInputs.forEach((input) => input.addEventListener("change", updateRole)); $("#togglePassword").addEventListener("click", (event) => { const visible = password.type === "text"; password.type = visible ? "password" : "text"; event.currentTarget.setAttribute("aria-label", visible ? "显示密码" : "隐藏密码"); event.currentTarget.title = visible ? "显示密码" : "隐藏密码"; }); form.addEventListener("submit", async (event) => { event.preventDefault(); errorBox.hidden = true; setLoading(true); const role = pendingRole || $('input[name="role"]:checked', form).value; if (pendingRole) { const newPassword = $('input[name="new_password"]', form).value; const confirmPassword = $('input[name="confirm_password"]', form).value; if (newPassword !== confirmPassword) { showError("两次输入的新密码不一致。"); setLoading(false); return; } const changeResponse = await fetch("/api/password/change", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ old_password: password.value, new_password: newPassword }), }).catch(() => null); const changeResult = await changeResponse?.json().catch(() => ({})); if (!changeResponse || !changeResponse.ok) { showError(changeResult?.message || "修改密码失败,请稍后重试。"); setLoading(false); return; } window.location.href = role === "admin" ? "admin.html" : "company.html"; return; } const response = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username.value.trim(), password: password.value, portal: role }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showError(result?.message || "登录服务暂时不可用,请稍后重试。"); setLoading(false); return; } if (result.must_change_password) { pendingRole = role; changeSection.hidden = false; setLoading(false); $('input[name="new_password"]', form).focus(); return; } window.location.href = role === "admin" ? "admin.html" : "company.html"; }); } async function initAuthGuard() { if (portal === "entry") return true; try { const response = await fetch("/api/me"); if (response.status === 401) { window.location.href = "index.html"; return false; } const me = await response.json(); if (!response.ok || me.role !== portal) { window.location.href = "index.html"; return false; } state.me = me; applyCompanyIdentity(me); return true; } catch { return true; } } function applyCompanyIdentity(me) { if (!me) return; const row = $(".side-foot .user-row"); if (row) { const name = me.username || (portal === "company" ? (me.company_name || "公司用户") : "系统管理员"); const avatar = $(".avatar", row); if (avatar) avatar.textContent = name.slice(0, 1); const nameEl = $(".user-name", row); if (nameEl) nameEl.textContent = name; const metaEl = $(".user-meta", row); if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "管理员"; } // The company portal always shows the session-bound company in page copy. if (portal === "company" && me.company_name) { $$(".company-identity").forEach((el) => { el.textContent = me.company_name; }); } } function initShell() { $$(".topbar").forEach((bar) => { if (bar.querySelector(".menu-button")) return; const button = document.createElement("button"); button.type = "button"; button.className = "icon-button menu-button"; button.setAttribute("aria-label", "打开导航"); button.setAttribute("aria-expanded", "false"); button.setAttribute("aria-controls", "sidebar"); button.title = "打开导航"; button.innerHTML = ''; bar.prepend(button); }); $$(".menu-button").forEach((menuButton) => { menuButton.addEventListener("click", () => { const open = $("#sidebar").classList.toggle("is-open"); menuButton.setAttribute("aria-expanded", String(open)); menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航"); }); }); $$(".side-nav a").forEach((item) => { const label = $("span", item)?.textContent.trim(); if (label) { item.setAttribute("aria-label", label); item.title = label; } }); $$("[data-view]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.view); })); document.addEventListener("click", (event) => { const button = event.target.closest("[data-view-link]"); if (!button) return; event.preventDefault(); showView(button.dataset.viewLink); }); $$(".logout").forEach((control) => control.addEventListener("click", async (event) => { event.preventDefault(); try { await fetch("/api/logout", { method: "POST" }); } catch { /* 网络异常时仍然回到登录页 */ } window.location.href = "index.html"; })); $$("[data-metric-link]").forEach((card) => { const activate = () => showView(card.dataset.metricLink); card.addEventListener("click", activate); card.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } }); }); $$("[data-metric-action=\"upload\"]").forEach((card) => { const activate = () => $("[data-open-upload]")?.click(); card.addEventListener("click", activate); card.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } }); }); // Delegated: company table rows are rendered from the API after init. document.addEventListener("click", (event) => { const toastButton = event.target.closest("[data-toast]"); if (toastButton) showToast(toastButton.dataset.toast); }); $(".side-nav a[data-view].active")?.setAttribute("aria-current", "page"); document.addEventListener("click", (event) => { if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true }); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeNavigation({ restoreFocus: true }); }); initDetailDrawer(); initMotion(); } function companyByName(name) { return (state.companies || []).find((item) => item.name === name); } const pairSubjectOrder = ["应收", "其他应收", "应付", "其他应付"]; async function setPair(from, to, endDate = "2026-07-31") { $$('[data-pair-from]').forEach((item) => { item.textContent = from; }); $$('[data-pair-to]').forEach((item) => { item.textContent = to; }); $$("[data-pair-form]").forEach((form) => { const fromSelect = $('[name="from"]', form); const toSelect = $('[name="to"]', form); if (fromSelect && [...fromSelect.options].some((option) => option.value === from)) fromSelect.value = from; if (toSelect && [...toSelect.options].some((option) => option.value === to)) toSelect.value = to; const endInput = $('[name="end"]', form); if (endInput) endInput.value = endDate; }); if (!$("#pairReport")) return; const fromCo = companyByName(from); const toCo = companyByName(to); const notice = $("#pairNotice"); if (!fromCo || !toCo) { state.pairRows = []; renderPairRows(); if (notice) notice.style.display = ""; return; } const start = state.calculationStart || "2026-01-01"; const pairResp = await fetch(`/api/admin/intercompany/pairs/${fromCo.id}/${toCo.id}?from=${encodeURIComponent(start)}&cutoff=${encodeURIComponent(endDate)}`).catch(() => null); const eventsResp = await fetch(`/api/admin/intercompany/events?company_a=${fromCo.id}&company_b=${toCo.id}&from=${encodeURIComponent(start)}&cutoff=${encodeURIComponent(endDate)}&limit=200`).catch(() => null); const pairResult = await pairResp?.json().catch(() => null); const eventsResult = await eventsResp?.json().catch(() => null); if (!pairResp?.ok || pairResult?.status !== "ok") { showToast("往来查询失败", pairResult?.message || "请稍后重试", "danger"); return; } const item = (pairResult.items || [])[0] || {}; const a = item.a || { period: {}, result: {} }; const debit = Number(a.period?.debit || 0); const credit = Number(a.period?.credit || 0); const opening = Number(item.opening?.amount || 0); const signed = Number(a.result?.signed_amount || (debit - credit)); $("#pairPeriod").textContent = `统计口径 ${start}—${endDate}`; $("#pairOpening").innerHTML = formatWanHtml(opening); $("#pairDebit").innerHTML = formatWanHtml(debit); $("#pairCredit").innerHTML = formatWanHtml(credit); const dirLabel = signed > 0 ? "应收" : signed < 0 ? "应付" : "持平"; $("#pairFinal").innerHTML = `${dirLabel} ${formatWanHtml(Math.abs(signed))}`; const unresolved = Number(item.unresolved?.count || 0); $("#pairReviewStatus").textContent = unresolved ? `含 ${unresolved} 笔待审核` : "无待审核"; const subjects = item.subjects || {}; const totals = { 应收: 0, 其他应收: 0, 应付: 0, 其他应付: 0 }; Object.values(subjects).forEach((bucket) => { const label = bucket.label || SUBJECT_CODE_LABEL[bucket.subject_code]; if (label && totals[label] != null) { totals[label] = Number(bucket.a_debit || 0) + Number(bucket.a_credit || 0); } }); const events = eventsResult?.items || []; $('[data-subject-total="all"]').textContent = `${events.length} 笔`; Object.entries(totals).forEach(([subject, value]) => { $(`[data-subject-total="${subject}"]`)?.replaceChildren(document.createTextNode(formatCurrency(yuanToWan(value) || 0))); }); if (notice) notice.style.display = events.length ? "none" : ""; state.pairContext = { from, to, endDate, fromId: fromCo.id, toId: toCo.id }; state.pairRows = events.map((event) => { const outgoing = Number(event.payer_company_id) === Number(fromCo.id); return { date: String(event.effective_at || "").slice(0, 10), direction: outgoing ? "转出" : "转入", subject: event.subject_label || SUBJECT_CODE_LABEL[event.subject_code] || "—", ownAccount: event.own_account_label || event.own_account || "—", counterparty: event.counterparty_company_name || (outgoing ? event.payee_company_name : event.payer_company_name) || to, summary: event.summary || event.purpose || "—", match: event.state === "confirmed" ? "双边匹配" : "待确认", amount: Number(event.amount || 0), serial: event.reference || event.source_id || "—", batch: event.import_batch_id ? `IMP-${String(event.import_batch_id).padStart(6, "0")}` : "—", peerAccount: event.counterparty_account || "—", ledgerEventId: event.ledger_event_id, }; }); renderPairRows(); } function renderPairRows() { const tbody = $("#pairTransactions"); if (!tbody) return; tbody.replaceChildren(); (state.pairRows || []).forEach((row, index) => { const tr = document.createElement("tr"); tr.dataset.subject = row.subject; tr.dataset.pairIdx = String(index); [["td", "num", row.date], ["td", "", row.direction], ["td", "", row.subject], ["td", "", row.ownAccount], ["td", "", row.counterparty], ["td", "wrap", row.summary]].forEach(([tag, cls, value]) => { const td = document.createElement(tag); if (cls) td.className = cls; td.textContent = value; tr.append(td); }); const matchTd = document.createElement("td"); const pill = document.createElement("span"); pill.className = `pill ${row.match === "双边匹配" ? "pill-success" : "pill-warn"}`; pill.textContent = row.match; matchTd.append(pill); const amt = document.createElement("td"); amt.className = `num-col ${row.direction === "转出" ? "amt-out" : "amt-in"}`; amt.textContent = formatCurrency(row.amount); const act = document.createElement("td"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn btn-sm"; btn.dataset.trace = String(index); btn.textContent = "穿透"; act.append(btn); tr.append(matchTd, amt, act); tbody.append(tr); }); } function openTrace(index) { const modal = $("#traceModal"); if (!modal) return; const row = state.pairRows?.[index]; if (!row) return; const ctx = state.pairContext || { from: "—", to: "—" }; $("#traceSub").textContent = `${ctx.from} ↔ ${ctx.to} · ${row.subject} · ${row.date} · ${row.direction} ${formatCurrency(row.amount)} 元`; $("#kvOwnTx").textContent = row.serial || "—"; $("#kvOwnAcct").textContent = row.ownAccount; $("#kvPeerCo").textContent = ctx.to; $("#kvPeerAcct").textContent = row.peerAccount || "—"; $("#kvTime").textContent = row.date; $("#kvAmt").textContent = `${formatCurrency(row.amount)} 元`; $("#kvBatch").textContent = row.batch || "—"; const evidence = $("#kvEvidence"); if (row.match === "双边匹配") { evidence.textContent = "已匹配"; evidence.style.color = ""; } else { evidence.textContent = "待对方提供"; evidence.style.color = "var(--warn)"; } modal.classList.add("open"); } function initPairQueries() { $$("[data-pair-form]").forEach((form) => { $("[data-swap]", form)?.addEventListener("click", () => { const from = $('[name="from"]', form); const to = $('[name="to"]', form); const previous = from.value; from.value = to.value; to.value = previous; }); form.addEventListener("submit", (event) => { event.preventDefault(); const from = $('[name="from"]', form).value; const to = $('[name="to"]', form).value; const endDate = $('[name="end"]', form)?.value || "2026-07-31"; const error = $("#pairError"); if (from === to) { if (error) { error.style.display = ""; } showToast("请选择两个不同的公司", "同公司账户调拨不进入公司间往来查询", "warn"); return; } if (error) error.style.display = "none"; const notice = $("#pairNotice"); if (notice) notice.style.display = "none"; setPair(from, to, endDate); showView("pair"); }); }); $$("[data-pair-link]").forEach((button) => button.addEventListener("click", () => { const [from, to] = button.dataset.pairLink.split("|"); setPair(from, to); showView("pair"); })); $$("[data-subject-filter]").forEach((button) => button.addEventListener("click", () => { const subject = button.dataset.subjectFilter; $$("[data-subject-filter]").forEach((item) => { const active = item === button; item.classList.toggle("active", active); item.setAttribute("aria-pressed", String(active)); }); $$("#pairTransactions tr").forEach((row) => { row.hidden = subject !== "all" && row.dataset.subject !== subject; }); })); $("#pairTransactions")?.addEventListener("click", (event) => { const button = event.target.closest("[data-trace]"); if (button) openTrace(Number(button.dataset.trace)); }); $("#traceClose")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open")); $("#traceOk")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open")); $("#traceModal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); }); } function fillAccountSelects(accounts) { const usable = accounts.filter((account) => account.usable); [$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => { if (!select) return; const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账"); select.replaceChildren(...kept); usable.forEach((account) => { const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`; const option = new Option(value, value); option.dataset.accountId = account.id; select.add(option); }); }); } function companyAccountMeta(status) { if (status === "active") return { status: { cls: "pill-success", label: "启用" }, audit: { cls: "pill-success", label: "已审核" } }; if (status === "returned") return { status: { cls: "pill-danger", label: "已退回" }, audit: { cls: "pill-danger", label: "已退回" } }; if (status === "disabled") return { status: { cls: "pill-muted", label: "停用" }, audit: { cls: "pill-success", label: "已审核" } }; return { status: { cls: "pill-info", label: "待启用" }, audit: { cls: "pill-info", label: "待审核" } }; } function renderCompanyAccounts(accounts) { state.accounts = accounts; const tbody = $("#account-tbody"); if (tbody) { tbody.replaceChildren(...accounts.map((account) => { const row = document.createElement("tr"); row.dataset.accountId = account.id; const identity = document.createElement("td"); const name = document.createElement("span"); name.className = "cell-main"; name.textContent = account.bank_name; const tail = document.createElement("span"); tail.className = "cell-sub"; tail.textContent = `尾号 ${accountTail(account.account_number_masked)}`; identity.append(name, tail); const type = document.createElement("td"); type.innerHTML = `${account.account_type || "—"}`; const meta = companyAccountMeta(account.status); const statusCell = document.createElement("td"); statusCell.innerHTML = `${meta.status.label}`; const coverage = document.createElement("td"); coverage.className = "num muted"; coverage.textContent = account.usable ? "尚未上传" : "—"; const auditCell = document.createElement("td"); auditCell.innerHTML = `${meta.audit.label}`; const action = document.createElement("td"); action.innerHTML = ''; row.append(identity, type, statusCell, coverage, auditCell, action); return row; })); } const count = $("#account-count"); if (count) count.textContent = accounts.length; const foot = $("#account-foot"); if (foot) foot.textContent = `共 ${accounts.length} 个账户`; const empty = $("#account-empty"); if (empty) empty.hidden = accounts.length > 0; fillAccountSelects(accounts); } async function loadCompanyAccounts() { const tbody = $("#account-tbody"); showTableLoading(tbody, 6); const response = await fetch("/api/company/accounts").catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } if (!response?.ok) { showTableError(tbody, 6); return; } const result = await response.json().catch(() => null); if (result?.accounts) renderCompanyAccounts(result.accounts); else showTableError(tbody, 6); } const manualSubjectLabels = { receivable: "应收", payable: "应付", other_receivable: "其他应收", other_payable: "其他应付", }; const manualDirectionLabels = { incoming: "收入", outgoing: "支出", }; function appendAdminReviewRow(record, kind) { const tbody = $("#auditRows"); if (!tbody) return; const isAccount = kind === "account"; const isManual = kind === "manual"; const statusLabel = isAccount ? accountStatusLabel(record.status) : (record.state === "pending" ? "待管理复核" : (record.state || "待复核")); const row = document.createElement("tr"); row.dataset.storedReview = String(record.id); row.dataset.recordId = String(record.id); row.dataset.recordKind = kind; if (isAccount) row.dataset.accountId = String(record.id); if (isManual) { row.dataset.decisionId = String(record.decision_id || ""); row.dataset.requestedSubject = record.requested_subject || ""; } row.dataset.auditType = isAccount ? "账户" : "手工"; row.dataset.company = isAccount ? (record.company_name || "") : (record.company_name || record.company || ""); row.dataset.evidence = isAccount ? "公司提交资料、开户行、账号、账户类型与启用日期" : "公司手工记录、关联银行流水号、证明附件与提交说明"; if (isAccount) { if (record.status !== "pending") row.dataset.resolved = "true"; row.dataset.accountStatus = record.status; } else if (record.state && record.state !== "pending") { row.dataset.resolved = "true"; } const riskCell = document.createElement("td"); riskCell.innerHTML = ''; const identityCell = document.createElement("td"); const identity = document.createElement("span"); identity.className = "cell-main"; const detail = document.createElement("span"); detail.className = "cell-sub"; if (isAccount) { identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`; detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`; } else { const subject = manualSubjectLabels[record.requested_subject] || record.requested_subject || "—"; const direction = manualDirectionLabels[record.direction] || record.direction || ""; const counterparty = record.counterparty_company_name || record.counterparty || "—"; identity.textContent = `${row.dataset.company} · 手工单 #${record.id}`; detail.textContent = `${direction} ${formatCurrency(record.amount)} 元 · ${counterparty} · ${subject}`; } identityCell.append(identity, detail); const typeCell = document.createElement("td"); typeCell.textContent = isAccount ? "账户登记" : "手工记录"; const periodCell = document.createElement("td"); if (isAccount) { periodCell.textContent = record.effective_from || "待审核确定"; } else { periodCell.className = "num"; periodCell.textContent = String(record.occurred_at || record.transactionDate || "").slice(0, 10) || "—"; } const impactCell = document.createElement("td"); impactCell.className = "wrap"; impactCell.textContent = isAccount ? "账户识别与流水上传" : `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`; const statusCell = document.createElement("td"); const status = recordStatus(statusLabel); statusCell.innerHTML = `${status.label}`; const actionCell = document.createElement("td"); if (row.dataset.resolved) { const isActiveAccount = isAccount && record.status === "active"; if (isActiveAccount) { actionCell.innerHTML = ''; } else { const resolvedLabel = isAccount ? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过" : (statusLabel === "已确认" || record.state === "approved" ? "已通过" : "已驳回"); actionCell.innerHTML = `${resolvedLabel} · 系统管理员`; } } else { actionCell.innerHTML = '
'; } row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell); tbody.append(row); } function appendMatchExceptionRow(item) { const tbody = $("#auditRows"); if (!tbody) return; const payer = item.payer_company_name || "—"; const payee = item.payee_company_name || "—"; const companyLabel = payer !== "—" ? payer : payee; const row = document.createElement("tr"); row.dataset.storedReview = `match-${item.event_id}`; row.dataset.recordId = String(item.event_id); row.dataset.recordKind = "match"; row.dataset.eventId = String(item.event_id); row.dataset.revision = String(item.revision ?? ""); row.dataset.auditType = "单边"; row.dataset.company = companyLabel; row.dataset.evidence = "匹配异常事件、观察流水、参与方与历史决定"; row.dataset.classification = item.classification || ""; const riskCell = document.createElement("td"); riskCell.innerHTML = ''; const identityCell = document.createElement("td"); const identity = document.createElement("span"); identity.className = "cell-main"; const detail = document.createElement("span"); detail.className = "cell-sub"; identity.textContent = `${payer} ↔ ${payee} · 事件 #${item.event_id}`; detail.textContent = `${item.classification || item.status || "unresolved"} · ${formatCurrency(item.amount)} ${item.currency || "CNY"} · 证据 ${item.evidence_count ?? 0} 条`; identityCell.append(identity, detail); const typeCell = document.createElement("td"); typeCell.textContent = "单边匹配"; const periodCell = document.createElement("td"); periodCell.className = "num"; periodCell.textContent = String(item.effective_at || "").slice(0, 10) || "—"; const impactCell = document.createElement("td"); impactCell.className = "wrap"; impactCell.textContent = `匹配异常 ${(Number(item.amount) / 10000).toFixed(2)} 万元 · 阻断往来归集`; const statusCell = document.createElement("td"); statusCell.innerHTML = '待处理'; const actionCell = document.createElement("td"); actionCell.innerHTML = '
'; row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell); tbody.append(row); } async function loadAdminAuditQueue() { const tbody = $("#auditRows"); if (!tbody) return; $$('[data-stored-review]', tbody).forEach((row) => row.remove()); showTableLoading(tbody, 7); const [accountsRes, manualsRes, exceptionsRes] = await Promise.all([ fetch("/api/admin/accounts?status=pending").catch(() => null), fetch("/api/admin/manual-records?state=pending").catch(() => null), fetch("/api/admin/match-exceptions").catch(() => null), ]); if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) { window.location.href = "index.html"; return; } if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) { showTableError(tbody, 7); showToast("审核队列加载失败", "请稍后重试", "danger"); return; } const [accountsPayload, manualsPayload, exceptionsPayload] = await Promise.all([ accountsRes.json().catch(() => null), manualsRes.json().catch(() => null), exceptionsRes.json().catch(() => null), ]); tbody.replaceChildren(); (accountsPayload?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account")); (manualsPayload?.records || []).forEach((record) => appendAdminReviewRow(record, "manual")); (exceptionsPayload?.exceptions || []).forEach((item) => appendMatchExceptionRow(item)); updateAuditCounts(); updatePendingAccountNotice(); await refreshAuditCountsFromApi(); // 列表条数必须与后端口径一致:不一致时以真实列表为准覆盖标题/角标,避免再出现「数字 3 / 列表 1」。 const unresolved = $$("#auditRows tr").filter((row) => row.dataset.resolved !== "true"); if (!state.dashAudit || Number(state.dashAudit.total) !== unresolved.length) { const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length; const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length; const low = Math.max(0, unresolved.length - high - medium); applyAuditCounts({ total: unresolved.length, high, medium, low }); } } function renderStoredAdminReviews() { if (!$("#auditRows")) return; // 完整队列:待复核账户 + 待审手工单 + 匹配异常,三处口径同源。 loadAdminAuditQueue(); } function updateAuditCounts() { const rows = $$("#auditRows tr").filter((row) => !row.classList.contains("loading-row")); const unresolved = rows.filter((row) => row.dataset.resolved !== "true"); $$('[data-audit-filter]').forEach((button) => { const type = button.dataset.auditFilter; const count = unresolved.filter((row) => type === "all" || row.dataset.auditType === type).length; const badge = $(".tab-count", button); if (badge) badge.textContent = count; }); const foot = $("#auditFoot"); if (foot) foot.textContent = `共 ${unresolved.length} 项 · 待审核 ${unresolved.length} 项`; // 角标 / 首页待审核卡 / 审核中心标题数:优先后端口径;列表加载完成后由 loadAdminAuditQueue 再对齐。 if (!state.dashAuditFromApi) { const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length; const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length; const low = Math.max(0, unresolved.length - high - medium); applyAuditCounts({ total: unresolved.length, high, medium, low }); } else { const pending = $("#pending-count"); if (pending) pending.textContent = String(unresolved.length); } } function applyAuditCounts(audit, { fromApi = false } = {}) { if (!audit) return; const total = Number(audit.total) || 0; const high = Number(audit.high) || 0; const medium = Number(audit.medium) || 0; const low = Number(audit.low) || 0; state.dashAudit = { total, high, medium, low }; if (fromApi) state.dashAuditFromApi = true; const totalEl = $("#dashAuditTotal"); const footEl = $("#dashAuditFoot"); if (totalEl) totalEl.innerHTML = `${total}`; if (footEl) footEl.textContent = `高 ${high} 项 · 中 ${medium} 项 · 其余 ${low} 项低风险`; const badge = $("#auditNavBadge") || $('.side-nav a[data-view="audit"] .nav-badge'); if (badge) { badge.textContent = String(total); badge.style.display = total > 0 ? "" : "none"; } const pending = $("#pending-count"); if (pending) pending.textContent = String(total); } async function refreshAuditCountsFromApi() { const from = await resolveDashStartDate(); const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null); if (!response?.ok) return null; const data = await response.json().catch(() => null); if (!data || data.status !== "ok" || !data.audit) return null; applyAuditCounts(data.audit, { fromApi: true }); return data.audit; } function companyStatusBadge(status) { if (status === "preparing") return { className: "neutral", label: "筹备中" }; if (status === "disabled") return { className: "danger", label: "已停用" }; return { className: "success", label: "正常" }; } function renderAdminCompanyTable(companies) { const tbody = $("#companyTable tbody"); if (!tbody) return; state.companies = companies; tbody.replaceChildren(...companies.map((company) => { const row = document.createElement("tr"); row.dataset.companyId = company.id; const nameCell = document.createElement("td"); const name = document.createElement("span"); name.className = "cell-main"; name.textContent = company.name; const code = document.createElement("span"); code.className = "cell-sub"; code.textContent = company.credit_code || "统一社会信用代码待补充"; nameCell.append(name, code); const accounts = document.createElement("td"); accounts.className = "num-col"; accounts.textContent = `${company.account_count ?? 0}`; const usernames = document.createElement("td"); usernames.className = "num"; usernames.textContent = company.usernames || "未创建"; const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定"; const statusCell = document.createElement("td"); const badge = companyStatusBadge(company.status); statusCell.innerHTML = `${badge.label}`; const actionCell = document.createElement("td"); actionCell.innerHTML = ``; row.append(nameCell, accounts, usernames, cashier, statusCell, actionCell); return row; })); const foot = $("#companyFoot"); if (foot) foot.textContent = `共 ${companies.length} 家公司`; } function updatePendingAccountNotice() { const rows = $$('#auditRows tr[data-record-kind="account"]'); const pending = rows.filter((r) => r.dataset.resolved !== "true"); const notice = $("#notice-pending-account"); if (!notice) return; if (pending.length) { notice.style.display = ""; $("#pending-account-count").textContent = pending.length; const names = [...new Set(pending.map((r) => r.dataset.company))].join("、"); $("#pending-account-body").textContent = (names || "成员公司") + " 提交了新银行账户登记,等待审核通过后纳入账期流水归集范围。"; } else { notice.style.display = "none"; } } function setSelectOptions(select, names, { keepFirst = false } = {}) { if (!select || !names.length) return; const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : []; select.replaceChildren(...kept, ...names.map((name) => new Option(name, name))); } function fillCompanySelects(names, companies) { // Every company picker is driven by master data: a newly created company // appears in pair queries, audit filters, flow filters and reminders // without any code change. $$("[data-pair-form]").forEach((form) => { setSelectOptions($('[name="from"]', form), names); setSelectOptions($('[name="to"]', form), names); const toSelect = $('[name="to"]', form); if (toSelect && names.length > 1) toSelect.value = names[1]; }); setSelectOptions($("#auditCompany"), names, { keepFirst: true }); setSelectOptions($("#flowCompany"), names, { keepFirst: true }); const reminderList = $("#reminderCompanyList"); if (reminderList && companies?.length) { reminderList.replaceChildren(...companies.map((company) => { const label = document.createElement("label"); label.className = "pick"; const input = document.createElement("input"); input.type = "checkbox"; input.name = "company"; input.value = String(company.id); label.append(input, document.createTextNode(company.name)); return label; })); syncPendingPicks(); } setSelectOptions($('#openingDialog [name="from"]'), names); setSelectOptions($('#openingDialog [name="to"]'), names); setSelectOptions($("#ql-self"), names); setSelectOptions($("#ql-peer"), names); if ($("#ql-peer") && names.length > 1) $("#ql-peer").value = names[0]; if ($("#ql-self") && names.length > 1) $("#ql-self").value = names[names.length - 1]; } function openingStatusPill(status) { if (status === "confirmed") return { cls: "pill-success", label: "已确认" }; if (status === "void") return { cls: "pill-danger", label: "已作废" }; if (status === "superseded") return { cls: "pill-muted", label: "已替代" }; return { cls: "pill-warn", label: "待确认" }; } function formatMoneyYuan(value) { const num = Number(value); if (Number.isNaN(num)) return "—"; return num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function humanizeChangeValue(value) { if (value == null) return "—"; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return String(value); } if (typeof value === "object") { const entries = Object.entries(value); if (!entries.length) return "—"; return entries.map(([k, v]) => `${k}=${v == null ? "空" : v}`).join(","); } return String(value); } function openModal(id) { $("#" + id)?.classList.add("open"); } function closeModal(id) { $("#" + id)?.classList.remove("open"); } function askReason({ title, subtitle, confirmLabel } = {}) { return new Promise((resolve) => { const dialog = $("#reasonDialog"); const form = $("#reasonForm"); const input = $("#reasonInput"); if (!dialog || !form || !input) { resolve(null); return; } const titleEl = $("#reasonDialogTitle"); const subEl = $("#reasonDialogSub"); const submitBtn = $("#reasonSubmit"); if (titleEl && title) titleEl.textContent = title; if (subEl && subtitle) subEl.textContent = subtitle; if (submitBtn && confirmLabel) submitBtn.textContent = confirmLabel; input.value = ""; const cleanup = () => { form.removeEventListener("submit", onSubmit); $$("[data-close-reason]").forEach((btn) => btn.removeEventListener("click", onCancel)); closeModal("reasonDialog"); }; const onCancel = () => { cleanup(); resolve(null); }; const onSubmit = (event) => { event.preventDefault(); const reason = String(input.value || "").trim(); if (reason.length < 2) { showToast("原因过短", "请至少填写 2 个字", "warn"); return; } cleanup(); resolve(reason); }; form.addEventListener("submit", onSubmit); $$("[data-close-reason]").forEach((btn) => btn.addEventListener("click", onCancel)); openModal("reasonDialog"); input.focus(); }); } async function loadCalculationSettings() { const response = await fetch("/api/admin/settings/calculation-start").catch(() => null); if (!response?.ok) return null; const data = await response.json().catch(() => null); if (!data) return null; const startInput = $("#cs-start"); const hint = $("#cs-start-hint"); const locked = $("#cs-start-locked"); if (startInput) { startInput.value = data.calculation_start_date || ""; startInput.disabled = Boolean(data.locked); startInput.dataset.locked = data.locked ? "1" : "0"; startInput.dataset.current = data.calculation_start_date || ""; } if (data.calculation_start_date) state.calculationStart = data.calculation_start_date; if (hint) { if (!data.calculation_start_date) { hint.textContent = "未设置起算日,系统暂按期间净变动口径显示"; hint.style.color = "var(--warn)"; } else { hint.textContent = "期初余额以此日前一日的期末数为准"; hint.style.color = ""; } } if (locked) locked.style.display = data.locked ? "" : "none"; const subtitle = $("#openingSub"); if (subtitle && data.calculation_start_date) { subtitle.textContent = `${data.calculation_start_date} 起算的公司间往来期初数`; } const summary = $("#openingSummary"); if (summary && data.summary) { summary.style.display = ""; summary.className = "notice info"; summary.replaceChildren(); const wrap = document.createElement("div"); const title = document.createElement("div"); title.className = "n-title"; title.textContent = `共 ${data.summary.company_count} 家公司 · 已确认 ${data.summary.confirmed_pair_count} 对公司对期初`; wrap.append(title); summary.append(wrap); } return data; } function appendEmptyRow(tbody, cols, text) { tbody.replaceChildren(); const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = cols; td.className = "empty"; td.textContent = text; tr.append(td); tbody.append(tr); } async function loadOpeningBalances() { const tbody = $("#openingRows"); if (!tbody) return; const response = await fetch("/api/admin/opening-balances").catch(() => null); if (!response?.ok) { appendEmptyRow(tbody, 8, "加载失败"); return; } const data = await response.json().catch(() => null); const items = data?.items || []; if (!items.length) { appendEmptyRow(tbody, 8, "暂无期初记录"); return; } tbody.replaceChildren(); items.forEach((item) => { const pill = openingStatusPill(item.status); const amount = Number(item.amount); const direction = amount >= 0 ? "应收" : "应付"; const tr = document.createElement("tr"); if (item.status === "void") tr.style.textDecoration = "line-through"; tr.dataset.openingId = String(item.id); const cells = [ ["td", "cell-main", item.company_low_name || "—"], ["td", "", item.company_high_name || "—"], ["td", "", direction], ["td", "num-col", formatMoneyYuan(Math.abs(amount))], ["td", "", item.actor_username || "—"], ["td", "num", (item.created_at || "").slice(0, 10)], ]; cells.forEach(([tag, cls, text]) => { const td = document.createElement(tag); if (cls) td.className = cls; if (cls === "" && text === direction) { td.style.color = amount >= 0 ? "var(--success)" : "var(--danger)"; } td.textContent = text; tr.append(td); }); const statusTd = document.createElement("td"); const span = document.createElement("span"); span.className = `pill ${pill.cls}`; span.textContent = pill.label; statusTd.append(span); tr.append(statusTd); const actionTd = document.createElement("td"); if (item.status === "draft") { const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn btn-sm"; btn.dataset.confirmOpening = String(item.id); btn.textContent = "确认"; actionTd.append(btn); } else if (item.status === "confirmed") { const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn btn-sm btn-ghost btn-danger"; btn.dataset.voidOpening = String(item.id); btn.textContent = "作废"; actionTd.append(btn); } tr.append(actionTd); tbody.append(tr); }); } async function loadCalculationChanges() { const tbody = $("#calculationChangeRows"); if (!tbody) return; const response = await fetch("/api/admin/calculation-changes").catch(() => null); if (!response?.ok) return; const data = await response.json().catch(() => null); const items = data?.items || []; if (!items.length) { appendEmptyRow(tbody, 5, "暂无变更记录"); return; } tbody.replaceChildren(); items.forEach((item) => { const tr = document.createElement("tr"); const values = [ (item.created_at || "").replace("T", " ").slice(0, 16), item.actor_username || "—", item.target || "—", `${humanizeChangeValue(item.before)} → ${humanizeChangeValue(item.after)}`, item.reason || "—", ]; values.forEach((text, index) => { const td = document.createElement("td"); if (index === 0) td.className = "num"; if (index >= 3) td.className = "wrap"; td.textContent = text; tr.append(td); }); tbody.append(tr); }); } function appendAuditGapRows(gaps) { const tbody = $("#auditRows"); if (!tbody) return; $$('#auditRows tr[data-audit-type="断档"]').forEach((row) => row.remove()); (gaps || []).forEach((gap) => { const tr = document.createElement("tr"); tr.dataset.auditType = "断档"; tr.dataset.gapId = String(gap.id || ""); tr.dataset.attestationId = gap.pending_attestation_id ? String(gap.pending_attestation_id) : ""; tr.dataset.accountId = String(gap.bank_account_id || ""); tr.dataset.gapStart = gap.gap_start || ""; tr.dataset.gapEnd = gap.gap_end || ""; const risk = document.createElement("td"); risk.innerHTML = ''; const company = document.createElement("td"); company.className = "cell-main"; company.textContent = `${gap.company_name || "—"} · ${gap.account_number_masked || gap.bank_account_id}`; const type = document.createElement("td"); type.textContent = "流水断档"; const period = document.createElement("td"); period.className = "num"; period.textContent = `${gap.gap_start || "—"} — ${gap.gap_end || "—"}`; const impact = document.createElement("td"); impact.className = "wrap"; impact.textContent = `${gap.gap_kind || "gap"} · ${gap.day_count || "?"} 天`; const status = document.createElement("td"); const statusPill = document.createElement("span"); statusPill.className = "pill pill-warn"; statusPill.textContent = gap.pending_attestation_id ? "待审说明" : "待补传"; status.append(statusPill); const action = document.createElement("td"); if (gap.pending_attestation_id) { action.innerHTML = '
'; } else { action.innerHTML = ''; } tr.append(risk, company, type, period, impact, status, action); tbody.prepend(tr); }); } async function loadAdminCoverageGaps() { const response = await fetch("/api/admin/coverage-gaps?status=open").catch(() => null); if (!response?.ok) return; const data = await response.json().catch(() => null); state.coverageGaps = data?.items || []; appendAuditGapRows(state.coverageGaps); updateAuditCounts(); const badge = $('.side-nav a[data-view="audit"] .nav-badge'); if (badge) { const n = state.coverageGaps.length || 0; badge.textContent = String(n || ""); badge.hidden = n <= 0; } } async function loadCompanyCoverageGaps() { const response = await fetch("/api/company/coverage-gaps").catch(() => null); if (!response?.ok) return []; const data = await response.json().catch(() => null); const items = (data?.items || []).filter((item) => item.status === "open"); state.companyCoverageGaps = items; const renderNotice = (rootId, bodyId) => { const root = $(rootId); const body = $(bodyId); if (!root || !body) return; if (!items.length) { root.style.display = "none"; return; } root.style.display = ""; body.textContent = items.slice(0, 3).map((g) => { const acct = g.account_number_masked || g.bank_account_id; return `${acct}:${g.gap_start} — ${g.gap_end}(${g.day_count || "?"}天)`; }).join(";") + (items.length > 3 ? ` 等 ${items.length} 处` : ""); }; renderNotice("#companyCoverageNotice", "#companyCoverageBody"); renderNotice("#flowsCoverageNotice", "#flowsCoverageBody"); return items; } function openAttestationDialog(gap) { if (!gap) { const gaps = state.companyCoverageGaps || []; gap = gaps[0]; } if (!gap) { showToast("暂无断档", "当前没有可说明的断档区间", "warn"); return; } $("#att-account-id").value = gap.bank_account_id || ""; $("#att-gap-start").value = gap.gap_start || ""; $("#att-gap-end").value = gap.gap_end || ""; const label = $("#att-gap-label"); if (label) { label.textContent = `${gap.account_number_masked || gap.bank_account_id} · ${gap.gap_start} — ${gap.gap_end}`; } $("#att-reason").value = ""; $("#att-evidence").value = ""; openModal("attestationDialog"); } function formatWan(value) { const num = Number(value); if (!Number.isFinite(num)) return "0.00"; const abs = Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); if (num > 0) return `+${abs}`; if (num < 0) return `-${abs}`; return abs; } function netClass(value) { const num = Number(value); if (num > 0) return "amt-in"; if (num < 0) return "amt-out"; return ""; } function updateDashAuditCard(audit) { applyAuditCounts(audit, { fromApi: true }); } function renderDashCompanyRows(companies, selectedId) { const list = $("#dashCompanyRows"); if (!list) return; if (!companies.length) { list.innerHTML = '
暂无公司或尚无归集往来
'; return; } const monthLabel = state.dashPeriodMonth ? `${state.dashPeriodMonth}月` : ""; list.replaceChildren(...companies.map((company) => { const item = document.createElement("div"); item.className = "dash-company-item"; item.setAttribute("role", "option"); item.dataset.companyId = company.id; item.tabIndex = 0; if (String(company.id) === String(selectedId)) { item.classList.add("is-selected"); item.setAttribute("aria-selected", "true"); } else { item.setAttribute("aria-selected", "false"); } const main = document.createElement("div"); main.className = "dash-company-main"; const name = document.createElement("span"); name.className = "dash-company-name"; name.title = company.name; name.textContent = company.name; const meta = document.createElement("span"); meta.className = "dash-company-meta"; const statusLabel = company.period_status_label || "—"; meta.textContent = monthLabel ? `${company.detail_count ?? 0}笔 · ${monthLabel} ${statusLabel}` : `${company.detail_count ?? 0}笔 · ${statusLabel}`; main.append(name, meta); const net = document.createElement("span"); net.className = `dash-company-net ${netClass(company.net_wan)}`; net.textContent = formatWan(company.net_wan); item.append(main, net); return item; })); } function chevronSvg(expanded) { // Same inline chevron style used elsewhere in the deployed admin shell. if (expanded) { return ''; } return ''; } function formatPlainWan(value) { const num = Number(value); if (!Number.isFinite(num)) return "0.00"; return Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function updateDashDetailSummary(company, payload) { const bar = $("#dashDetailSummary"); if (!bar) return; if (!company) { bar.hidden = true; return; } bar.hidden = false; const count = payload?.groups ? payload.groups.reduce((sum, g) => sum + Number(g.count || 0), 0) : Number(company.detail_count || 0); const debit = payload?.groups ? payload.groups.reduce((sum, g) => sum + Number(g.debit_wan || 0), 0) : Number(company.debit_wan || 0); const credit = payload?.groups ? payload.groups.reduce((sum, g) => sum + Number(g.credit_wan || 0), 0) : Number(company.credit_wan || 0); const ending = payload?.groups ? payload.groups.reduce((sum, g) => sum + Number(g.ending_wan || 0), 0) : Number(company.net_wan || 0); const setText = (id, text, className = "") => { const el = $(id); if (!el) return; el.textContent = text; el.className = `dash-summary-value num ${className}`.trim(); }; setText("#dashSumCount", String(count)); setText("#dashSumOpening", "—", "meta"); setText("#dashSumDebit", formatPlainWan(debit)); setText("#dashSumCredit", formatPlainWan(credit)); setText("#dashSumEnding", formatWan(ending), netClass(ending)); } function showDashDetailEmpty(company) { const wrap = $("#dashDetailWrap"); const empty = $("#dashDetailEmpty"); const desc = $("#dashDetailEmptyDesc"); if (wrap) wrap.hidden = true; if (empty) empty.hidden = false; if (desc) { const cutoff = state.dashCutoff || "—"; // 方案 A 空态:两行克制说明;期初口径仍为不可用(—),不伪造 0.00 desc.textContent = `期初 — · 截至 ${cutoff} 无明细记录 · 导入流水并归集后在此展示`; } updateDashDetailSummary(null); } function showDashDetailTable() { const wrap = $("#dashDetailWrap"); const empty = $("#dashDetailEmpty"); if (wrap) wrap.hidden = false; if (empty) empty.hidden = true; } function renderDashDetail(payload, { expandFirst = false, company = null } = {}) { const tbody = $("#dashDetailRows"); const title = $("#dashDetailTitle"); if (!tbody) return; const selected = company || (state.dashCompanies || []).find( (c) => String(c.id) === String(payload?.company_id || state.dashSelectedId) ) || null; if (!payload) { if (title) title.textContent = "请选择左侧公司"; showDashDetailTable(); tbody.innerHTML = '请选择左侧公司'; updateDashDetailSummary(null); return; } if (title) title.textContent = payload.company_name || selected?.name || "公司往来明细"; const sub = $("#dashDetailSub"); if (sub) sub.textContent = "按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列"; const groups = payload.groups || []; if (!groups.length) { showDashDetailEmpty(selected || { detail_count: 0, debit_wan: 0, credit_wan: 0, net_wan: 0 }); tbody.replaceChildren(); return; } showDashDetailTable(); updateDashDetailSummary(selected, payload); const rows = []; groups.forEach((group, index) => { const expanded = expandFirst && index === 0; const groupTr = document.createElement("tr"); groupTr.className = "clickable"; groupTr.dataset.peerGroup = String(group.peer_id); groupTr.dataset.peerName = group.peer_name; groupTr.dataset.expanded = expanded ? "true" : "false"; groupTr.innerHTML = ` ${chevronSvg(expanded)}${group.peer_name} ${group.count} — ${Number(group.debit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${Number(group.credit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${formatWan(group.ending_wan)}`; rows.push(groupTr); (group.lines || []).forEach((line) => { const lineTr = document.createElement("tr"); lineTr.dataset.peerChild = String(group.peer_id); lineTr.style.display = expanded ? "" : "none"; const dirTag = line.direction === "debit" ? '' : ''; lineTr.innerHTML = ` ${dirTag} ${line.date} ${line.summary} 1 — ${line.direction === "debit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"} ${line.direction === "credit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"} —`; rows.push(lineTr); }); }); tbody.replaceChildren(...rows); } async function resolveDashStartDate() { if (state.dashFrom) return state.dashFrom; const response = await fetch("/api/admin/settings").catch(() => null); if (response?.ok) { const result = await response.json().catch(() => null); const startDate = result?.settings?.start_date; if (startDate) { state.dashFrom = startDate; return startDate; } } return "2026-01-01"; } async function loadDashCompanyDetail(companyId) { if (!companyId) return; state.dashSelectedId = companyId; const company = (state.dashCompanies || []).find((c) => String(c.id) === String(companyId)) || null; renderDashCompanyRows(state.dashCompanies || [], companyId); const title = $("#dashDetailTitle"); if (title && company) title.textContent = company.name; updateDashDetailSummary(company, null); showDashDetailTable(); const tbody = $("#dashDetailRows"); if (tbody) tbody.innerHTML = '加载中…'; const from = await resolveDashStartDate(); const response = await fetch(`/api/admin/dashboard/companies/${companyId}?from=${encodeURIComponent(from)}`).catch(() => null); if (!response?.ok) { if (tbody) tbody.innerHTML = '明细加载失败'; return; } const result = await response.json().catch(() => null); renderDashDetail(result, { company }); } async function loadAdminDashboard() { if (!$("#dashCompanyRows")) return; const from = await resolveDashStartDate(); const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null); if (!response?.ok) { $("#dashCompanyRows").innerHTML = '
总览加载失败
'; return; } const data = await response.json().catch(() => null); if (!data || data.status !== "ok") { $("#dashCompanyRows").innerHTML = '
总览加载失败
'; return; } state.dashCompanies = data.companies || []; state.dashFrom = data.from_date; state.dashCutoff = data.cutoff; state.dashPeriodMonth = data.period_month; updateDashAuditCard(data.audit); const listSub = $("#dashListSub"); if (listSub) { listSub.textContent = `${data.from_date} 至 ${data.cutoff} · 单位:万元 · 左侧选择公司,右侧查看其往来明细`; } const meta = $("#dashMasterMeta"); if (meta) { const totals = data.totals || {}; const companyCount = totals.company_count ?? state.dashCompanies.length; const detailCount = totals.detail_count ?? 0; meta.textContent = `${companyCount} 家公司 · 明细 ${detailCount} 笔`; } const selected = state.dashSelectedId || state.dashCompanies[0]?.id; renderDashCompanyRows(state.dashCompanies, selected); if (selected) await loadDashCompanyDetail(selected); } function initDashboard() { if (!$("#companyMasterDetail")) return; const selectCompany = (companyId) => { if (!companyId) return; loadDashCompanyDetail(companyId); }; $("#dashCompanyRows")?.addEventListener("click", (event) => { const item = event.target.closest(".dash-company-item[data-company-id]"); if (!item) return; selectCompany(item.dataset.companyId); }); $("#dashCompanyRows")?.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; const item = event.target.closest(".dash-company-item[data-company-id]"); if (!item) return; event.preventDefault(); selectCompany(item.dataset.companyId); }); $("#dashDetailRows")?.addEventListener("click", (event) => { const tr = event.target.closest("tr[data-peer-group]"); if (!tr) return; const peerId = tr.dataset.peerGroup; const open = tr.dataset.expanded !== "true"; tr.dataset.expanded = open ? "true" : "false"; const nameCell = tr.children[0]; if (nameCell) { nameCell.innerHTML = `${chevronSvg(open)}${tr.dataset.peerName || ""}`; } $$('#dashDetailRows tr[data-peer-child]').forEach((child) => { if (child.dataset.peerChild === peerId) child.style.display = open ? "" : "none"; }); }); $("#company-search")?.addEventListener("input", function () { const q = this.value.trim(); $$("#dashCompanyRows .dash-company-item[data-company-id]").forEach((item) => { item.style.display = (!q || item.textContent.includes(q)) ? "" : "none"; }); }); loadAdminDashboard(); } function formatReminderTime(value) { if (!value) return "—"; const text = String(value).replace("T", " ").slice(0, 16); return text; } function reminderTypePill(ruleKey) { if (ruleKey === "pending_review") return "pill-warn"; if (ruleKey === "manual") return "pill-warn"; return "pill-danger"; } function reminderStatusPill(statusUi) { return { unread: "pill-danger", doing: "pill-warn", done: "pill-success" }[statusUi] || "pill-muted"; } function reminderStatusLabel(statusUi) { return { unread: "未读", doing: "处理中", done: "已完成" }[statusUi] || statusUi; } function updatePickCount() { const countEl = $("#pick-count"); if (!countEl) return; const n = $$('#reminderCompanyList input[name="company"]:checked').length; countEl.textContent = "已选 " + n + " 家"; } function syncPendingPicks() { $$(".pending-check:checked").forEach((input) => { const companyId = input.closest(".list-row")?.dataset.companyId; if (!companyId) return; const chip = document.querySelector('#reminderCompanyList input[name="company"][value="' + companyId + '"]'); if (chip) chip.checked = true; }); updatePickCount(); } async function loadAdminRemindersPending() { const list = $("#pending-list"); const empty = $("#pending-empty"); const summary = $("#pending-summary"); const sendAll = $("#pending-send-all"); if (!list) return; const response = await fetch("/api/admin/reminders/pending").catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response?.ok) return; const findings = result.findings || []; list.querySelectorAll(".list-row").forEach((row) => row.remove()); if (!findings.length) { if (empty) empty.style.display = ""; if (summary) summary.textContent = "0 家公司 · 0 项"; if (sendAll) { sendAll.disabled = true; sendAll.textContent = "全部一键发送"; } return; } if (empty) empty.style.display = "none"; const companies = new Set(findings.map((item) => item.company_id)).size; if (summary) summary.textContent = `${companies} 家公司 · ${findings.length} 项`; if (sendAll) { sendAll.disabled = false; sendAll.textContent = "全部一键发送"; sendAll.dataset.keys = JSON.stringify(findings.map((item) => item.dedupe_key)); } findings.forEach((item) => { const row = document.createElement("div"); row.className = "list-row"; row.dataset.dedupeKey = item.dedupe_key; row.dataset.companyId = String(item.company_id); const sentHint = item.send_count > 0 ? ` · 已提醒 ${item.send_count} 次` : ""; row.innerHTML = '' + `${item.rule_label}` + `
${item.title}
` + `
${item.reason}${sentHint}
` + `${item.days_open} 天` + `
`; list.append(row); }); } async function loadAdminRemindersHistory(sourceFilter) { const tbody = $("#reminder-tbody"); if (!tbody) return; const query = sourceFilter && sourceFilter !== "all" ? `?source=${sourceFilter === "system" ? "auto" : "manual"}` : ""; const response = await fetch(`/api/admin/reminders${query}`).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response?.ok) return; const items = result.reminders || []; tbody.replaceChildren(...items.map((item) => { const tr = document.createElement("tr"); tr.dataset.source = item.source === "auto" ? "system" : "manual"; tr.dataset.reminderId = String(item.id); const summary = item.content.length > 46 ? item.content.slice(0, 46) + "…" : item.content; const sourceCell = item.source === "auto" ? '系统' : '人工'; tr.innerHTML = `${item.company_name}` + `${sourceCell}` + `${item.display_type}` + `${summary.replace(/` + `${formatReminderTime(item.last_sent_at)}` + `${item.deadline || "—"}` + `${reminderStatusLabel(item.status_ui)}` + '
'; return tr; })); refreshReminderCounts(result.stats || {}); } function refreshReminderCounts(stats) { const rows = $$("#reminder-tbody tr"); let all = rows.length, sys = 0, man = 0; rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; }); $("#count-all").textContent = all; $("#count-system").textContent = sys; $("#count-manual").textContent = man; $("#table-foot-count").textContent = `共 ${all} 条提醒记录`; if (stats) { $("#table-foot-state").textContent = `未读 ${stats.unread || 0} · 处理中 ${stats.doing || 0} · 已完成 ${stats.done || 0}`; } } async function sendPendingReminders(keys) { const response = await fetch("/api/admin/reminders/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dedupe_keys: keys }), }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return null; } const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("发送失败", result?.message || "请稍后重试", "danger"); return null; } return result; } function openReminderDetailDrawer(reminderId) { fetch(`/api/admin/reminders/${reminderId}`) .then((response) => response.json()) .then((result) => { const item = result.reminder; if (!item) return; $("#rd-title").textContent = item.title; $("#rd-sub").textContent = item.content; const sourcePill = $("#rd-source-pill"); if (sourcePill) { sourcePill.textContent = item.source === "auto" ? "系统" : "人工"; sourcePill.className = item.source === "auto" ? "pill pill-info" : "tag"; } $("#rd-fields").innerHTML = `
公司
${item.company_name}
` + `
类型
${item.display_type}
` + `
触发原因
${item.rule_params?.pending_count ? `待处理 ${item.rule_params.pending_count} 项` : (item.rule_params?.period || "—")}
` + `
发送时间
${formatReminderTime(item.last_sent_at)}
` + `
催办次数
${item.send_count}
` + `
处理状态
${reminderStatusLabel(item.status_ui)}
`; $("#rd-events").innerHTML = (item.events || []).map((ev) => `
${ev.event_type}
${formatReminderTime(ev.created_at)} · ${ev.actor}${ev.detail ? " · " + ev.detail : ""}
`, ).join("") || '
暂无事件
'; $("#reminder-detail-drawer")?.classList.add("is-open"); }) .catch(() => showToast("加载详情失败", "", "danger")); } function initAdminReminders() { loadAdminRemindersPending(); loadAdminRemindersHistory("all"); $("#reminder-scan-btn")?.addEventListener("click", async () => { const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("扫描失败", result?.message || "请稍后重试", "danger"); return; } await loadAdminRemindersPending(); showToast("扫描完成", `发现 ${result.summary?.items || 0} 项待提醒`, "success"); }); $("#pending-send-all")?.addEventListener("click", async () => { const checked = $$(".pending-check:checked"); let keys; if (checked.length) { keys = checked.map((input) => input.closest(".list-row")?.dataset.dedupeKey).filter(Boolean); } else { keys = JSON.parse($("#pending-send-all").dataset.keys || "[]"); } if (!keys.length) return; const result = await sendPendingReminders(keys); if (!result) return; showToast(checked.length ? "已发送选中提醒" : "已全部发送", `共 ${result.count} 项`, "success"); await loadAdminRemindersPending(); await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all"); }); $("#pending-list")?.addEventListener("click", async (event) => { const btn = event.target.closest(".pending-send-one"); if (!btn) return; const row = btn.closest(".list-row"); const key = row?.dataset.dedupeKey; if (!key) return; const result = await sendPendingReminders([key]); if (!result) return; if (!motionQuery.matches) { row.style.transition = "opacity 0.2s ease"; row.style.opacity = "0"; setTimeout(() => row.remove(), 200); } else { row.remove(); } showToast("已发送提醒", "", "success"); await loadAdminRemindersPending(); await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all"); }); $("#pending-list")?.addEventListener("change", (event) => { if (!event.target.classList.contains("pending-check")) return; const checked = $$(".pending-check:checked").length; const sendAll = $("#pending-send-all"); if (sendAll) sendAll.textContent = checked ? `发送选中(${checked})` : "全部一键发送"; if (event.target.checked) { const companyId = event.target.closest(".list-row")?.dataset.companyId; const chip = companyId && document.querySelector('#reminderCompanyList input[name="company"][value="' + companyId + '"]'); if (chip) chip.checked = true; } updatePickCount(); }); $("#reminderCompanyList")?.addEventListener("change", (event) => { if (event.target.name === "company") updatePickCount(); }); $("#reminderForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked')); const type = $("#reminder-type").value; const content = $("#reminder-content").value.trim(); const deadline = $("#reminder-deadline").value; $("#company-error").style.display = checked.length ? "none" : ""; $("#content-error").style.display = content ? "none" : ""; if (!checked.length || !content) return; for (const input of checked) { const response = await fetch("/api/admin/reminders/manual", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: Number(input.value), display_type: type, content, deadline: deadline || null, }), }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("发送失败", result?.message || "请稍后重试", "danger"); return; } } const companies = checked.map((c) => { const company = (state.companies || []).find((item) => String(item.id) === c.value); return company?.name || c.value; }).join("、"); form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; }); updatePickCount(); const sendHint = $("#send-hint"); sendHint.style.display = ""; sendHint.textContent = `已发送给 ${companies},共 ${checked.length} 家公司。`; clearTimeout(sendHint._t); sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000); showToast("人工提醒已发送", companies, "success"); await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all"); }); $("#reminder-tabs")?.addEventListener("click", (event) => { const btn = event.target.closest("button[data-filter]"); if (!btn) return; $("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); loadAdminRemindersHistory(btn.dataset.filter); }); $("#reminder-tbody")?.addEventListener("click", async (event) => { const detailBtn = event.target.closest(".act-detail"); if (detailBtn) { const tr = detailBtn.closest("tr"); openReminderDetailDrawer(tr.dataset.reminderId); return; } const remindBtn = event.target.closest(".act-remind"); if (!remindBtn) return; const tr = remindBtn.closest("tr"); const response = await fetch(`/api/admin/reminders/${tr.dataset.reminderId}/resend`, { method: "POST" }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("再提醒失败", result?.message || "请稍后重试", "danger"); return; } remindBtn.textContent = "已再提醒"; remindBtn.disabled = true; await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all"); showToast("已再次发送提醒", "", "success"); }); $$("[data-close-rd]").forEach((btn) => btn.addEventListener("click", () => { $("#reminder-detail-drawer")?.classList.remove("is-open"); })); } async function loadAdminCompanies() { const tbody = $("#companyTable tbody"); showTableLoading(tbody, 6); const response = await fetch("/api/admin/companies").catch(() => null); if (!response?.ok) { showTableError(tbody, 6); return; } const result = await response.json().catch(() => null); const companies = result?.companies || []; state.companies = companies; renderAdminCompanyTable(companies); fillCompanySelects(companies.map((company) => company.name), companies); if (companies.length >= 2) setPair(companies[0].name, companies[1].name); } function nextYearMonth(ym) { const [y, m] = String(ym).split("-").map(Number); const month = m === 12 ? 1 : m + 1; const year = m === 12 ? y + 1 : y; return `${year}-${String(month).padStart(2, "0")}`; } function collapseTimeline(items) { if (window.innerWidth > 460 || !items?.length) return items; const locked = items.filter((item) => item.cell === "locked"); const rest = items.filter((item) => item.cell !== "locked"); if (locked.length < 2) return items; return [ { year_month: `${locked[0].year_month}—${locked[locked.length - 1].year_month.slice(5)}`, cell: "locked", label: "均已锁定", merged: true, }, ...rest, ]; } function renderTimeline(items) { const root = $("#periodTimeline"); if (!root) return; root.replaceChildren(); collapseTimeline(items || []).forEach((item) => { const cell = document.createElement("div"); const klass = item.cell === "locked" ? "locked" : item.cell === "current" ? "current" : item.cell === "reopened" ? "reopened" : item.cell === "failed" ? "failed" : "open"; cell.className = `tl-cell ${klass}`; const month = document.createElement("div"); month.className = "tl-month"; month.textContent = item.year_month; const stateEl = document.createElement("div"); stateEl.className = "tl-state"; stateEl.textContent = item.label; cell.append(month, stateEl); root.append(cell); }); } function setClosingChecks(checks) { (checks || []).forEach((check) => { const row = $(`#closingPanel [data-closing-key="${check.key}"]`); if (!row) return; const ok = !!check.ok; const mark = $("[data-closing-check]", row); const sub = $("[data-closing-sub]", row); const stateEl = $("[data-closing-state]", row); if (mark) { mark.className = `pill ${ok ? "pill-success" : "pill-danger"}`; mark.textContent = check.title; } if (sub) sub.textContent = check.detail || "—"; if (stateEl) { stateEl.className = `pill ${ok ? "pill-success" : check.blocking ? "pill-danger" : "pill-warn"}`; stateEl.textContent = ok ? "已通过" : check.blocking ? "阻断" : "待处理"; } }); } function applyClosePanel(overview) { state.periodClose = overview; const close = overview.close || {}; const ym = close.year_month || overview.target_month || "—"; renderTimeline(overview.timeline); const start = state.calculationStart || "—"; if ($("#timelineSub")) $("#timelineSub").textContent = `全局起算日 ${start} 起,每月 ${overview.closing_day || "—"} 日结账`; setClosingChecks(close.checks); $("#ckMonth").textContent = ym; const totals = close.snapshot?.totals || {}; const net = totals.net_wan ?? totals.debit_wan; if ($("#ckNet")) $("#ckNet").textContent = net != null ? `${formatCurrency(net)} 万元` : "—"; $("#ckCarry").textContent = ym !== "—" ? `结转至 ${nextYearMonth(ym)} 期初` : "—"; $("#ckReport").textContent = close.report_no || "尚未生成"; ["block-notice", "failed-notice", "closed-notice", "reopened-notice"].forEach((id) => { const el = $(`#${id}`); if (el) el.style.display = "none"; }); const status = $("#closingStatus"); const btn = $("#executeClosing"); const panel = $("#closingPanel"); const busy = $("#closingBusy"); const dl = $("#downloadMonthReport"); panel?.classList.remove("is-processing"); if (busy) busy.hidden = true; if (dl) dl.hidden = !close.report_no; const history = overview.history; if ($("#closingHistory")) { $("#closingHistory").textContent = history ? `最近:${history.year_month} · ${history.closed_by_username || "—"} · ${String(history.closed_at || "").slice(0, 16).replace("T", " ")}` : "尚无结账记录"; } const desc = $("#closingDescription"); if (close.status === "closing") { panel?.classList.add("is-processing"); if (busy) busy.hidden = false; status.className = "pill pill-info"; status.textContent = "处理中"; desc.textContent = `${ym} · 正在锁定账期并生成月报`; btn.disabled = true; return; } if (close.status === "failed") { status.className = "pill pill-danger"; status.textContent = "结账失败"; desc.textContent = `${ym} · 未改动任何数据`; const failed = $("#failed-notice"); if (failed) { failed.style.display = ""; $("#failed-notice-body").textContent = `${close.fail_reason || "结账失败"} · 未改动任何数据,可重新执行。`; } btn.disabled = !close.ready; btn.className = "btn btn-primary"; btn.textContent = "重新执行结账"; return; } if (close.status === "closed" || close.locked) { status.className = "pill pill-lock"; status.textContent = "已锁定"; desc.textContent = `${ym} · 已结账锁定`; const closed = $("#closed-notice"); if (closed) { closed.style.display = ""; $("#closed-notice-title").textContent = `${ym} 已结账`; $("#closed-notice-body").textContent = `月报 ${close.report_no || "—"} · 结账人 ${close.closed_by_username || "—"} · ${String(close.closed_at || "").slice(0, 16).replace("T", " ")}`; } btn.disabled = false; btn.className = "btn"; btn.textContent = `申请重开 ${ym} 账期`; btn.dataset.mode = "reopen"; return; } if (close.status === "reopened") { status.className = "pill pill-warn"; status.textContent = "已重开"; const days = close.reopen_remaining_days; desc.textContent = `${ym} · 重开窗口内可更正`; const reopened = $("#reopened-notice"); if (reopened) { reopened.style.display = ""; $("#reopened-notice-body").textContent = `审批通过后窗口截止 ${close.reopen_window_end || "—"} · 剩 ${days == null ? "—" : days} 天,到期自动恢复锁定。`; } btn.disabled = !close.ready; btn.className = "btn btn-warn"; btn.textContent = "提前结束重开并重新结账"; btn.dataset.mode = "close"; return; } const blocked = (close.blockers || []).length > 0 || !close.ready; status.className = `pill ${blocked ? "pill-danger" : "pill-success"}`; status.textContent = blocked ? "已阻断" : "可结账"; desc.textContent = blocked ? `${ym} · 当前未达到结账条件` : `${ym} · 全部前置检查已通过`; if (blocked) { const block = $("#block-notice"); if (block) { block.style.display = ""; $("#block-notice-title").textContent = `存在阻断项,暂不能执行 ${ym} 月度结账`; $("#block-notice-body").textContent = (close.blockers || []).map((item) => item.detail).join(";") || "请先处理待审核事项与账户断档。"; } } btn.disabled = blocked; btn.className = "btn btn-primary"; btn.textContent = `执行 ${ym} 月度结账`; btn.dataset.mode = "close"; if (blocked) btn.title = "请先处理全部阻断事项"; else btn.removeAttribute("title"); } async function loadPeriodClose() { if (!$("#closingPanel")) return; const response = await fetch("/api/admin/period-closes").catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => null); if (!response?.ok || result?.status !== "ok") { showToast("结账状态加载失败", result?.message || "请稍后重试", "danger"); return; } applyClosePanel(result); } function initPeriodClose() { $("#runClosingCheck")?.addEventListener("click", () => loadPeriodClose()); $("#downloadMonthReport")?.addEventListener("click", () => { const ym = state.periodClose?.close?.year_month; if (ym) window.location.href = `/api/admin/period-closes/${ym}/report.json`; }); $("#executeClosing")?.addEventListener("click", () => { const mode = $("#executeClosing")?.dataset.mode || "close"; const ym = state.periodClose?.close?.year_month || ""; if (mode === "reopen") { $("#reopenRequestTitle").textContent = `申请重开 ${ym} 账期`; openModal("reopenRequestDialog"); return; } $("#closingDialogTitle").textContent = `确认执行 ${ym} 月度结账`; $("#closingDialogSub").textContent = `结账后 ${ym} 流水与往来确认将锁定,不可再直接修改。`; $("#cdMonth").textContent = ym; $("#cdChecks").textContent = (state.periodClose?.close?.checks || []).every((c) => c.ok) ? "全部前置检查已通过" : "仍有未通过项"; $("#cdCarry").textContent = `往来净额将结转至 ${nextYearMonth(ym)} 期初`; const box = $("#closingConfirmBox"); if (box) box.checked = false; openModal("closingDialog"); }); $$("[data-close-closing]").forEach((button) => button.addEventListener("click", () => closeModal("closingDialog"))); $("#closingForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const ym = state.periodClose?.close?.year_month; if (!ym || !$("#closingConfirmBox")?.checked) { showToast("请先勾选确认", "须复核结账结果并知晓锁定后果", "warn"); return; } closeModal("closingDialog"); $("#closingPanel")?.classList.add("is-processing"); if ($("#closingBusy")) $("#closingBusy").hidden = false; const response = await fetch(`/api/admin/period-closes/${ym}/execute`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm: true }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("结账失败", result?.message || "未改动任何数据", "danger"); await loadPeriodClose(); return; } showToast(`${ym} 已完成结账`, `月报 ${result.report_no || ""} 已生成并锁定`, "success"); await loadPeriodClose(); }); $$("[data-close-reopen-req]").forEach((button) => button.addEventListener("click", () => closeModal("reopenRequestDialog"))); $("#reopenRequestForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const ym = state.periodClose?.close?.year_month; const reason = $("#reopenReason")?.value.trim() || ""; if (reason.length < 10) { showToast("原因过短", "重开原因不少于 10 个字,将写入审计记录", "warn"); return; } const response = await fetch(`/api/admin/period-closes/${ym}/reopen`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason, companies_note: $("#reopenCompanies")?.value || "", window_days: Number($("#reopenDays")?.value || 3), }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("申请失败", result?.message || "请稍后重试", "danger"); return; } closeModal("reopenRequestDialog"); showToast("重开申请已提交", result.item?.number || "", "success"); await loadPeriodClose(); await loadReopenQueue(); }); $$("[data-close-reopen-dec]").forEach((button) => button.addEventListener("click", () => closeModal("reopenDecideDialog"))); $("#reopenApproveBtn")?.addEventListener("click", () => decideReopen(true)); $("#reopenRejectBtn")?.addEventListener("click", () => decideReopen(false)); $("#paApply")?.addEventListener("click", () => loadPeriodAudit()); $("#paReload")?.addEventListener("click", () => loadPeriodAudit()); $("#paReset")?.addEventListener("click", () => { ["paSince", "paUntil", "paCompany"].forEach((id) => { if ($(`#${id}`)) $(`#${id}`).value = ""; }); if ($("#paAction")) $("#paAction").value = ""; loadPeriodAudit(); }); } function renderDiffGrid(diff) { const root = $("#reopenDiff"); if (!root) return; root.replaceChildren(); (diff || []).forEach((row) => { const wrap = document.createElement("div"); wrap.className = `diff-row${row.changed ? " changed" : ""}`; const label = document.createElement("div"); label.className = "diff-label"; label.textContent = row.path || "状态"; const before = document.createElement("div"); before.className = "diff-before"; before.textContent = row.before == null ? "—" : typeof row.before === "object" ? JSON.stringify(row.before) : String(row.before); const arrow = document.createElement("div"); arrow.className = "diff-arrow"; arrow.textContent = "→"; const after = document.createElement("div"); after.className = "diff-after"; after.textContent = row.after == null ? "—" : typeof row.after === "object" ? JSON.stringify(row.after) : String(row.after); wrap.append(label, before, arrow, after); root.append(wrap); }); } async function openReopenDecide(id) { const response = await fetch(`/api/admin/period-reopens/${id}`).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("加载失败", result?.message || "请稍后重试", "danger"); return; } const item = result.item || {}; state.reopenDecideId = item.id; $("#reopenDecideTitle").textContent = `重开审批 · ${item.year_month}`; $("#reopenDecideSub").textContent = `${item.number || ""} · ${item.requester_username || ""} · ${String(item.requested_at || "").slice(0, 16).replace("T", " ")}`; const kv = $("#reopenDecideKv"); if (kv) { kv.replaceChildren(); [["账期", item.year_month], ["原因", item.reason], ["涉及", item.companies_note || "—"], ["窗口", `${item.window_days} 天`], ["原月报", item.report_no || "—"]].forEach(([dt, dd]) => { const t = document.createElement("dt"); t.textContent = dt; const d = document.createElement("dd"); d.textContent = dd; kv.append(t, d); }); } renderDiffGrid(item.diff); $("#reopenComment").value = ""; $("#reopenApproveBtn").textContent = `同意重开 ${item.year_month}`; openModal("reopenDecideDialog"); } async function decideReopen(approve) { const id = state.reopenDecideId; const comment = $("#reopenComment")?.value.trim() || ""; if (!approve && comment.length < 2) { showToast("请填写驳回意见", "驳回必须填写审批意见", "warn"); return; } const response = await fetch(`/api/admin/period-reopens/${id}/decide`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approve, comment }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("审批失败", result?.message || "请稍后重试", "danger"); return; } closeModal("reopenDecideDialog"); showToast(approve ? "已同意重开" : "已驳回申请", result.item?.number || "", approve ? "warn" : "danger"); await loadPeriodClose(); await loadReopenQueue(); } function reopenStatusPill(status) { if (status === "approved") return { cls: "pill-warn", label: "已通过" }; if (status === "rejected") return { cls: "pill-danger", label: "已驳回" }; return { cls: "pill-info", label: "待审批" }; } async function loadReopenQueue() { const list = $("#reopenQueueList"); if (!list) return; const response = await fetch("/api/admin/period-reopens").catch(() => null); const result = await response?.json().catch(() => ({})); const items = result.items || []; const pending = items.filter((item) => item.status === "pending").length; if ($("#reopenTabCount")) $("#reopenTabCount").textContent = String(pending); list.replaceChildren(); items.forEach((item) => { const row = document.createElement("div"); row.className = "list-row"; const pill = document.createElement("span"); const meta = reopenStatusPill(item.status); pill.className = `pill ${meta.cls}`; pill.textContent = meta.label; const main = document.createElement("div"); main.className = "lr-main"; const title = document.createElement("div"); title.className = "lr-title"; title.textContent = `申请重开 ${item.year_month}`; const sub = document.createElement("div"); sub.className = "lr-sub"; sub.textContent = item.reason || ""; const metaLine = document.createElement("div"); metaLine.className = "meta"; metaLine.textContent = `${item.number} · ${item.requester_username || ""} · ${String(item.requested_at || "").slice(0, 16).replace("T", " ")}`; main.append(title, sub, metaLine); const side = document.createElement("div"); side.className = "lr-side"; if (item.status === "pending") { const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn btn-sm btn-primary"; btn.textContent = "审批"; btn.addEventListener("click", () => openReopenDecide(item.id)); side.append(btn); } else { const num = document.createElement("span"); num.className = "meta"; num.textContent = item.number; side.append(num); } row.append(pill, main, side); list.append(row); }); const empty = $("#reopenQueueEmpty"); if (empty) empty.hidden = items.length > 0; } function auditActionPill(action) { if (action === "close_execute") return { cls: "pill-success", label: "执行月结" }; if (action === "reopen_request") return { cls: "pill-info", label: "重开申请" }; if (action === "reopen_approve") return { cls: "pill-warn", label: "重开审批通过" }; if (action === "close_fail") return { cls: "pill-danger", label: "月结失败" }; if (action === "reopen_reject") return { cls: "pill-danger", label: "重开驳回" }; return { cls: "pill-muted", label: action || "—" }; } async function loadPeriodAudit() { const tbody = $("#periodAuditRows"); if (!tbody) return; showTableLoading(tbody, 6); $("#paError").style.display = "none"; const params = new URLSearchParams(); if ($("#paSince")?.value) params.set("since", $("#paSince").value); if ($("#paUntil")?.value) params.set("until", $("#paUntil").value); if ($("#paAction")?.value) params.set("action", $("#paAction").value); if ($("#paCompany")?.value) params.set("company", $("#paCompany").value); const response = await fetch(`/api/admin/period-audit?${params.toString()}`).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showTableError(tbody, 6); $("#paError").style.display = ""; return; } const items = result.items || []; tbody.replaceChildren(); const cards = $("#periodAuditCards"); if (cards) cards.replaceChildren(); items.forEach((item) => { const tr = document.createElement("tr"); const time = document.createElement("td"); time.className = "num"; time.textContent = String(item.created_at || "").slice(0, 19).replace("T", " "); const actor = document.createElement("td"); const name = document.createElement("div"); name.textContent = item.actor_username || "—"; const role = document.createElement("div"); role.className = "cell-sub"; role.textContent = item.actor_role || ""; actor.append(name, role); const action = document.createElement("td"); const pill = document.createElement("span"); const meta = auditActionPill(item.action); pill.className = `pill ${meta.cls}`; pill.textContent = meta.label; action.append(pill); const obj = document.createElement("td"); obj.className = "wrap"; obj.textContent = `${item.year_month || ""} ${item.reason || item.object_label || ""}`.trim(); const change = document.createElement("td"); change.className = "num"; const before = item.before?.snapshot_hash || item.before?.status || ""; const after = item.after?.snapshot_hash || item.after?.status || ""; change.textContent = before || after ? `${String(before).slice(0, 8)} → ${String(after).slice(0, 8)}` : "—"; const proof = document.createElement("td"); proof.textContent = item.report_no || "—"; tr.append(time, actor, action, obj, change, proof); tbody.append(tr); if (cards) { const card = document.createElement("div"); card.className = "card"; card.style.padding = "12px 14px"; [["时间", time.textContent], ["操作人", item.actor_username], ["动作", meta.label], ["对象", obj.textContent], ["变化", change.textContent], ["凭证", item.report_no || "—"]].forEach(([k, v]) => { const line = document.createElement("div"); line.className = "row-between"; const l = document.createElement("span"); l.className = "meta"; l.textContent = k; const r = document.createElement("span"); r.textContent = v || "—"; line.append(l, r); card.append(line); }); cards.append(card); } }); $("#periodAuditFoot").textContent = `共 ${items.length} 条`; const empty = $("#periodAuditEmpty"); if (empty) empty.hidden = items.length > 0; } function initAdmin() { renderStoredAdminReviews(); updateAuditCounts(); loadAdminCompanies(); loadCalculationSettings(); loadOpeningBalances(); loadCalculationChanges(); loadAdminCoverageGaps(); initDashboard(); loadPeriodClose(); loadReopenQueue(); let activeAuditType = "all"; function filterAuditRows() { const company = $("#auditCompany")?.value || "全部公司"; $$(".audit-table tbody tr").forEach((row) => { const typeMatches = activeAuditType === "all" || row.dataset.auditType === activeAuditType; const companyMatches = company === "全部公司" || row.dataset.company === company; row.hidden = !(typeMatches && companyMatches); }); } $$("[data-audit-filter]").forEach((button) => button.addEventListener("click", () => { activeAuditType = button.dataset.auditFilter; $$("[data-audit-filter]").forEach((item) => { const active = item === button; item.classList.toggle("active", active); item.setAttribute("aria-pressed", String(active)); }); const reopen = activeAuditType === "reopen"; if ($("#auditTableWrap")) $("#auditTableWrap").hidden = reopen; if ($("#reopenQueue")) $("#reopenQueue").hidden = !reopen; if (reopen) loadReopenQueue(); else filterAuditRows(); })); $("#auditCompany")?.addEventListener("change", filterAuditRows); document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close))); document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); })); document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); }); async function submitAuditResult(row) { const decision = state.auditDecision; const reason = state.auditReason || ""; const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用") || decision.includes("关闭异常"); const returned = decision.includes("退回"); const kind = row.dataset.recordKind; let storedStatus; let reviewedAccount = null; if (kind === "account" && row.dataset.accountId) { 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) { if (!toastIfLocked(result)) showToast("审核结果提交失败", result?.message || "请稍后重试", "danger"); return; } reviewedAccount = result.account; storedStatus = accountStatusLabel(result.account?.status); } else if (kind === "manual" && row.dataset.recordId) { const action = approved ? "approve_new" : "return"; const response = await fetch(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, reason: reason || decision, expected_decision_id: row.dataset.decisionId ? Number(row.dataset.decisionId) : null, request_key: `audit-manual-${row.dataset.recordId}-${Date.now()}`, subject_code: row.dataset.requestedSubject || undefined, }), }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { if (!toastIfLocked(result)) showToast("手工单审核失败", result?.message || "请稍后重试", "danger"); return; } storedStatus = approved ? "已确认" : "已退回"; } else if (kind === "match" && row.dataset.eventId) { // 既有 decision:撤销当前匹配决定,使异常退出待办队列(与 reverse 语义一致)。 const response = await fetch(`/api/admin/transfer-events/${row.dataset.eventId}/decisions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "reverse", reason: reason || decision || "审核中心关闭匹配异常", expected_revision: row.dataset.revision ? Number(row.dataset.revision) : null, request_key: `audit-match-${row.dataset.eventId}-${Date.now()}`, }), }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("匹配异常处置失败", result?.message || "请稍后重试", "danger"); return; } storedStatus = approved ? "已确认" : "已退回"; } else { showToast("审核结果提交失败", "未知待办类型", "danger"); return; } // 处置后从列表移除并重拉三队列,保证页脚/过滤/标题与角标同步减一。 row.remove(); updateAuditCounts(); await refreshAuditCountsFromApi(); const unresolved = $$("#auditRows tr").filter((r) => r.dataset.resolved !== "true"); const high = unresolved.filter((r) => r.querySelector(".pill-danger")).length; const medium = unresolved.filter((r) => r.querySelector(".pill-warn")).length; const low = Math.max(0, unresolved.length - high - medium); applyAuditCounts({ total: unresolved.length, high, medium, low }, { fromApi: true }); updatePendingAccountNotice(); filterAuditRows(); showToast( "审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算", "success", ); } $("#auditRows")?.addEventListener("click", async (event) => { const button = event.target.closest("[data-audit-action]"); if (!button) return; if (button.dataset.auditAction === "approve-attestation" || button.dataset.auditAction === "reject-attestation") { const row = button.closest("tr"); const attestationId = row?.dataset.attestationId; if (!attestationId) return; const approve = button.dataset.auditAction === "approve-attestation"; const reason = await askReason({ title: approve ? "通过无业务说明" : "驳回无业务说明", subtitle: "审核结论将写入留痕;通过后仅关闭断档,不生成银行行。", confirmLabel: approve ? "通过" : "驳回", }); if (!reason) return; const response = await fetch(`/api/admin/no-business-attestations/${attestationId}/review`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision: approve ? "approve" : "reject", review_reason: reason }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("审核失败", result?.message || "请稍后重试", "danger"); return; } await loadAdminCoverageGaps(); showToast(approve ? "已通过说明" : "已驳回说明", "断档状态已更新", "success"); return; } const row = button.closest("tr"); state.auditRow = row; const cells = $$("td", row); const company = cells[1]?.querySelector(".cell-main")?.textContent.trim() || ""; const period = cells[3]?.textContent.trim() || ""; const type = cells[2]?.textContent.trim() || ""; const basis = row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"; const action = button.dataset.auditAction; if (action === "approve") { $("#approve-company").textContent = company; $("#approve-period").textContent = period; $("#approve-type").textContent = type; $("#approve-basis").textContent = basis; openModal("modal-approve"); } else if (action === "reject") { $("#reject-reason").value = ""; $("#reject-hint").style.display = "none"; openModal("modal-reject"); } else if (action === "disable") { state.auditDecision = "停用并驳回"; state.auditReason = "停用并驳回该账户"; submitAuditResult(row); } }); $("#approve-confirm")?.addEventListener("click", () => { if (!state.auditRow) return; closeModal("modal-approve"); const kind = state.auditRow.dataset.recordKind; state.auditDecision = kind === "account" ? "复核通过并启用账户" : kind === "match" ? "关闭异常" : "确认并纳入计算"; state.auditReason = state.auditDecision; submitAuditResult(state.auditRow); }); $("#reject-confirm")?.addEventListener("click", () => { if (!state.auditRow) return; const reason = $("#reject-reason").value.trim(); if (reason.length < 5) { $("#reject-hint").style.display = ""; return; } closeModal("modal-reject"); const kind = state.auditRow.dataset.recordKind; state.auditDecision = kind === "account" ? "退回公司修改" : kind === "match" ? "退回重匹配" : "退回公司补充材料"; state.auditReason = reason; submitAuditResult(state.auditRow); }); $("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog")); $("#companyForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const data = new FormData(form); const companyName = String(data.get("companyName") || "").trim(); const loginName = String(data.get("loginName") || "").trim(); const createUser = data.get("createUser") !== null; const payload = { name: companyName, credit_code: String(data.get("creditCode") || "").trim(), cashier_name: String(data.get("cashier") || "").trim(), }; if (createUser && loginName) payload.username = loginName; const response = await fetch("/api/admin/companies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("公司创建失败", result?.message || "请稍后重试", "danger"); return; } const accountCreated = Boolean(result.username); closeModal("companyDialog"); form.reset(); await loadAdminCompanies(); showToast( accountCreated ? "公司与账号已创建" : "公司已创建", accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号", "success", ); }); function openCompanyDetail(companyId) { const company = (state.companies || []).find((c) => String(c.id) === String(companyId)); if (!company) return; $("#d-title").textContent = company.name; $("#d-sub").textContent = `统一社会信用代码 ${company.credit_code || "待补充"}`; $("#d-kv").innerHTML = '
公司名称
' + company.name + '
' + '
统一社会信用代码
' + (company.credit_code || "待补充") + '
' + '
出纳
' + (company.cashier_name || "未指定") + '
' + '
银行账户
' + (company.account_count ?? 0) + ' 个
' + '
状态
' + companyStatusBadge(company.status).label + '
'; $("#d-login-account").textContent = company.usernames || "未创建公司账号"; $("#d-login-meta").textContent = company.usernames ? "公司端登录账号" : "该公司尚未创建登录账号"; const resetBtn = $("#btn-reset-pwd"); resetBtn.disabled = !company.usernames; resetBtn.textContent = "重置密码"; resetBtn.dataset.companyId = company.id; openModal("modal-detail"); } $("#companyTable tbody")?.addEventListener("click", (event) => { const btn = event.target.closest("[data-company-view]"); if (btn) openCompanyDetail(btn.dataset.companyView); }); $("#btn-reset-pwd")?.addEventListener("click", async () => { const companyId = $("#btn-reset-pwd").dataset.companyId; const usersResponse = await fetch("/api/admin/users").catch(() => null); const usersResult = await usersResponse?.json().catch(() => ({})); const user = (usersResult?.users || []).find((u) => String(u.company_id) === String(companyId) && u.role === "company"); if (!user) { showToast("重置失败", "该公司尚无公司登录账号", "danger"); return; } const response = await fetch(`/api/admin/users/${user.id}/reset-password`, { method: "POST" }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; } $("#btn-reset-pwd").disabled = true; $("#btn-reset-pwd").textContent = "已重置"; $("#d-login-meta").textContent = `临时密码已生成(仅此一次):${result.initial_password},首次登录必须修改`; showToast("密码已重置", "临时密码仅本次显示,首次登录必须修改", "success"); }); const remind = $("#cs-remind"); const track = $("#cs-switch-track"); const thumb = $("#cs-switch-thumb"); const daysSel = $("#cs-remind-days"); function renderSwitch() { if (!remind) return; track.style.background = remind.checked ? "var(--accent)" : "var(--fg-soft)"; thumb.style.left = remind.checked ? "18px" : "2px"; if (daysSel) daysSel.disabled = !remind.checked; } remind?.addEventListener("change", renderSwitch); renderSwitch(); function applySystemSettings(values) { if (!values) return; const start = $("#cs-start"); if (start && values.start_date) start.value = values.start_date; const dayInput = $("#cs-day"); if (dayInput && values.closing_day) dayInput.value = values.closing_day; const remindInput = $("#cs-remind"); if (remindInput && "auto_remind" in values) { remindInput.checked = values.auto_remind === "1"; renderSwitch(); } const daysSelect = $("#cs-remind-days"); if (daysSelect && values.remind_days) daysSelect.value = values.remind_days; const closingDay = values.closing_day || "5"; const startDate = values.start_date || "2026-01-01"; const dash = $("#dashboardPeriodSub"); if (dash) dash.textContent = `每月 ${closingDay} 日结账 · 全局起算日 ${startDate}`; const timeline = $("#timelineSub"); if (timeline) timeline.textContent = `全局起算日 ${startDate} 起,每月 ${closingDay} 日结账`; const flows = $("#flowsRangeMeta"); if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账`; const opening = $("#openingSub"); if (opening) opening.textContent = `${startDate} 起算的公司间往来期初数`; } async function loadSystemSettings() { const response = await fetch("/api/admin/settings").catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (response?.ok && result.settings) applySystemSettings(result.settings); } $("#systemSettings")?.addEventListener("submit", async (event) => { event.preventDefault(); const tip = $("#cs-save-tip"); const day = parseInt($("#cs-day")?.value, 10); if (!day || day < 1 || day > 28) { if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; } return; } const startInput = $("#cs-start"); const nextStart = startInput?.value || ""; const prevStart = startInput?.dataset.current || ""; const locked = startInput?.dataset.locked === "1"; if (nextStart !== prevStart) { if (locked) { showToast("起算日已锁定", "已有结账月份,起算日不可修改", "warn"); return; } const reason = await askReason({ title: "修改起算日", subtitle: "修改起算日必须填写原因,并写入变更留痕。", confirmLabel: "确认修改", }); if (!reason) return; const calcResp = await fetch("/api/admin/settings/calculation-start", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ calculation_start_date: nextStart, reason }), }).catch(() => null); if (calcResp?.status === 401) { window.location.href = "index.html"; return; } const calcResult = await calcResp?.json().catch(() => ({})); if (!calcResp?.ok) { showToast("起算日保存失败", calcResult?.message || "请稍后重试", "danger"); return; } await loadCalculationSettings(); await loadCalculationChanges(); } const payload = { closing_day: String(day), auto_remind: $("#cs-remind")?.checked ? "1" : "0", remind_days: $("#cs-remind-days")?.value || "3", }; // Keep display start_date in settings store in sync when calculation start exists. if (nextStart) payload.start_date = nextStart; const response = await fetch("/api/admin/settings", { 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) { const message = result?.message || "保存失败,请稍后重试"; if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = message; } showToast("设置保存失败", message, "danger"); return; } if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; } applySystemSettings(result.settings || {}); await loadCalculationSettings(); const gapDays = parseInt($("#cs-remind-days")?.value, 10) || 5; const reminderResp = await fetch("/api/admin/reminder-settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ settings: { monthly_start_day: String(day || 5), gap_days: String(gapDays) } }), }).catch(() => null); if (reminderResp?.status === 401) { window.location.href = "index.html"; return; } if (!reminderResp?.ok) { showToast("提醒扫描参数保存失败", "", "danger"); return; } showToast("系统计算口径已保存", "结账日与起算日变更已留痕 · 提醒扫描参数已同步更新", "success"); }); initPeriodClose(); loadPeriodClose(); $("#openOpeningDialog")?.addEventListener("click", () => openModal("openingDialog")); $$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog"))); $("#openingForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const data = new FormData(form); const fromName = data.get("from"); const toName = data.get("to"); if (fromName === toName) { showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初", "warn"); return; } const fromCompany = (state.companies || []).find((c) => c.name === fromName); const toCompany = (state.companies || []).find((c) => c.name === toName); if (!fromCompany || !toCompany) { showToast("公司无效", "请刷新页面后重试", "warn"); return; } const amount = Number(data.get("amount")); if (Number.isNaN(amount)) { showToast("金额无效", "请输入有效数字", "warn"); return; } const response = await fetch("/api/admin/opening-balances", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_company_id: fromCompany.id, to_company_id: toCompany.id, amount: String(amount), reason: String(data.get("reason") || ""), }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("提交失败", result?.message || "请稍后重试", "danger"); return; } closeModal("openingDialog"); form.reset(); await loadOpeningBalances(); await loadCalculationChanges(); await loadCalculationSettings(); showToast("期初余额已提交", "待确认后才会计入公司端余额", "success"); }); $("#openingRows")?.addEventListener("click", async (event) => { const confirmBtn = event.target.closest("[data-confirm-opening]"); const voidBtn = event.target.closest("[data-void-opening]"); const id = confirmBtn?.dataset.confirmOpening || voidBtn?.dataset.voidOpening; if (!id) return; const reason = await askReason({ title: confirmBtn ? "确认期初余额" : "作废期初余额", subtitle: "该操作必须填写原因,并写入变更留痕。", confirmLabel: confirmBtn ? "确认" : "作废", }); if (!reason) return; const path = confirmBtn ? `/api/admin/opening-balances/${id}/confirm` : `/api/admin/opening-balances/${id}/void`; const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("操作失败", result?.message || "请稍后重试", "danger"); return; } await loadOpeningBalances(); await loadCalculationChanges(); showToast(confirmBtn ? "期初已确认" : "期初已作废", "变更已留痕", "success"); }); initAdminReminders(); loadSystemSettings(); } async function loadFlows() { const tbody = $("#flowTable tbody"); if (!tbody) return; const params = new URLSearchParams(); const companyName = $("#flowCompany")?.value; if (companyName && companyName !== "全部公司") { const company = companyByName(companyName); if (company) params.set("company_id", String(company.id)); } const bank = $("#flowBank")?.value; if (bank && bank !== "全部银行") params.set("bank", bank); const account = $("#flowAccount")?.value; if (account && account !== "全部账户") params.set("account", account); if ($("#flowStart")?.value) params.set("start", $("#flowStart").value); if ($("#flowEnd")?.value) params.set("end", $("#flowEnd").value); if ($("#flowKeyword")?.value.trim()) params.set("keyword", $("#flowKeyword").value.trim()); params.set("limit", "200"); const response = await fetch(`/api/flows?${params.toString()}`).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => null); if (!response?.ok || result?.status !== "ok") { showTableError(tbody, portal === "admin" ? 9 : 8); showToast("流水加载失败", result?.message || "请稍后重试", "danger"); return; } state.flowRows = result.items || []; state.flowTotal = result.total; renderFlowRows(); } function renderFlowRows() { const tbody = $("#flowTable tbody"); if (!tbody) return; const data = state.flowRows || []; tbody.replaceChildren(); data.forEach((row, index) => { const tr = document.createElement("tr"); tr.className = "clickable"; tr.dataset.flowIdx = String(index); tr.dataset.company = row.company || ""; tr.dataset.bank = row.bank || ""; tr.dataset.account = row.account || ""; tr.dataset.date = row.date || ""; tr.dataset.dir = row.direction || ""; const date = document.createElement("td"); date.className = "num"; date.textContent = row.date || "—"; tr.append(date); if (portal === "admin") { const company = document.createElement("td"); company.className = "cell-main"; company.textContent = row.company || "—"; tr.append(company); } const acct = document.createElement("td"); acct.textContent = row.account_label || row.account || "—"; const dir = document.createElement("td"); const dirPill = document.createElement("span"); dirPill.className = `pill ${row.direction === "收" ? "pill-success" : "pill-danger"}`; dirPill.textContent = row.direction || "—"; dir.append(dirPill); const peer = document.createElement("td"); peer.className = "wrap"; peer.textContent = row.peer || "—"; const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = row.summary || "—"; const serial = document.createElement("td"); serial.className = "num"; serial.textContent = row.serial || "—"; const status = document.createElement("td"); const statusPill = document.createElement("span"); const kind = row.status_kind === "success" ? "pill-success" : row.status_kind === "warn" ? "pill-warn" : row.status_kind === "danger" ? "pill-danger" : "pill-muted"; statusPill.className = `pill ${kind}`; statusPill.textContent = row.status || "未归集"; status.append(statusPill); const amt = document.createElement("td"); amt.className = `num-col ${row.direction === "收" ? "amt-in" : "amt-out"}`; amt.textContent = `¥ ${formatCurrency(row.amount)}`; tr.append(acct, dir, peer, summary, serial, status, amt); tbody.append(tr); }); const count = $("#flowCount"); if (count) count.textContent = `共 ${resultTotalLabel(data.length, state.flowTotal)}`; const empty = $("#flowEmpty"); if (empty) empty.hidden = data.length > 0; const sum = $("#flowSum"); if (sum) { sum.replaceChildren(); if (!data.length) return; const inflow = document.createElement("span"); inflow.className = "amt-in"; inflow.textContent = `+¥ ${formatCurrency(data.filter((r) => r.direction === "收").reduce((n, r) => n + Number(r.amount || 0), 0))}`; const outflow = document.createElement("span"); outflow.className = "amt-out"; outflow.textContent = `-¥ ${formatCurrency(data.filter((r) => r.direction !== "收").reduce((n, r) => n + Number(r.amount || 0), 0))}`; sum.append(document.createTextNode("收 "), inflow, document.createTextNode(" · 付 "), outflow); } } function resultTotalLabel(shown, total) { if (total != null && total !== shown) return `${total} 笔 · 本页 ${shown}`; return `${shown} 笔`; } function openFlowDetail(index) { const modal = $("#tx-modal"); if (!modal) return; const row = (state.flowRows || [])[index]; if (!row) return; $("#tx-modal-sub").textContent = `${row.company || ""} · ${row.account_label || ""} · ${row.date || ""}`; $("#d-serial").textContent = row.serial || "—"; $("#d-bank").textContent = row.bank || "—"; $("#d-account").textContent = row.own_name ? `${row.own_name} · ${row.account || ""}` : (row.account || "—"); $("#d-time").textContent = row.time || row.date || "—"; $("#d-peer").textContent = row.peer || "—"; $("#d-peer-acct").textContent = row.peer_account || "—"; $("#d-amount").textContent = `¥ ${formatCurrency(row.amount)}(${row.direction || ""})`; $("#d-status").textContent = row.status || "—"; $("#d-pair").textContent = "—"; $("#d-subject").textContent = "—"; $("#d-batch").textContent = row.batch || "—"; $("#d-note").textContent = row.locator ? `源行 ${row.locator}` : "—"; modal.classList.add("open"); } function exportFlows() { const params = new URLSearchParams(); const companyName = $("#flowCompany")?.value; if (companyName && companyName !== "全部公司") { const company = companyByName(companyName); if (company) params.set("company_id", String(company.id)); } window.location.href = `/api/export.csv${params.toString() ? `?${params}` : ""}`; showToast("开始导出", "仅含出纳已确认工作表的银行原始流水", "success"); } function initFlowTools() { $("#applyFlowFilters")?.addEventListener("click", loadFlows); $("#exportFlows")?.addEventListener("click", exportFlows); $("#resetFlowFilters")?.addEventListener("click", () => { const company = $("#flowCompany"); if (company) company.value = "全部公司"; if ($("#flowBank")) $("#flowBank").value = "全部银行"; if ($("#flowAccount")) $("#flowAccount").value = "全部账户"; if ($("#flowStart")) $("#flowStart").value = ""; if ($("#flowEnd")) $("#flowEnd").value = ""; if ($("#flowKeyword")) $("#flowKeyword").value = ""; loadFlows(); }); $("#flowTable tbody")?.addEventListener("click", (event) => { const row = event.target.closest("tr[data-flow-idx]"); if (row) openFlowDetail(Number(row.dataset.flowIdx)); }); $("#tx-modal-close")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open")); $("#tx-modal-ok")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open")); $("#tx-modal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); }); } function manualApiStatus(stateName) { if (stateName === "approved") return { cls: "pill-success", label: "已通过" }; if (stateName === "returned" || stateName === "reversed") return { cls: "pill-danger", label: stateName === "returned" ? "已退回" : "已冲销" }; if (stateName === "exception") return { cls: "pill-danger", label: "异常" }; return { cls: "pill-info", label: "待审核" }; } function renderCompanyManualRecords(records) { const tbody = $("#manualRecordRows"); if (!tbody) return; tbody.replaceChildren(); const rows = records || state.manualRecords || []; rows.forEach((record) => { const tr = document.createElement("tr"); tr.dataset.recordId = String(record.id); const date = document.createElement("td"); date.className = "num"; date.textContent = String(record.occurred_at || "").slice(0, 10); const direction = document.createElement("td"); direction.textContent = record.direction === "incoming" ? "收款" : "付款"; const counterparty = document.createElement("td"); const name = document.createElement("span"); name.className = "cell-main"; name.textContent = record.counterparty_company_name || "—"; counterparty.append(name); const subject = document.createElement("td"); const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = SUBJECT_CODE_LABEL[record.requested_subject] || record.requested_subject || "—"; subject.append(tag); const isIn = record.direction === "incoming"; const amount = document.createElement("td"); amount.className = `num-col ${isIn ? "amt-in" : "amt-out"}`; amount.textContent = `${isIn ? "+" : "-"}¥ ${formatCurrency(record.amount)}`; const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = record.summary || "—"; const statusMeta = manualApiStatus(record.state); const statusCell = document.createElement("td"); const pill = document.createElement("span"); pill.className = `pill ${statusMeta.cls}`; pill.textContent = statusMeta.label; statusCell.append(pill); const action = document.createElement("td"); action.innerHTML = ''; tr.append(date, direction, counterparty, subject, amount, summary, statusCell, action); tbody.append(tr); }); const pending = rows.filter((record) => record.state === "pending").length; const pendingEl = $("#manualPendingStatus"); if (pendingEl) pendingEl.textContent = pending; const foot = $("#manualFoot"); if (foot) foot.textContent = `共 ${rows.length} 条 · 待复核 ${pending} 条`; const empty = $("#manualEmpty"); if (empty) empty.hidden = rows.length > 0; } async function loadCompanyManualRecords() { if (!$("#manualRecordRows")) return; const response = await fetch("/api/company/manual-records").catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("手工记录加载失败", result?.message || "请稍后重试", "danger"); return; } state.manualRecords = result.records || []; renderCompanyManualRecords(state.manualRecords); } async function loadCompanyPeerSelect() { const select = $("#manualCounterparty"); if (!select) return; const response = await fetch("/api/company/companies").catch(() => null); const result = await response?.json().catch(() => ({})); const companies = (result.companies || []).filter((item) => String(item.id) !== String(state.me?.company_id)); const first = select.options[0]; select.replaceChildren(first); companies.forEach((company) => { const option = document.createElement("option"); option.value = String(company.id); option.textContent = company.name; select.append(option); }); } function resetUpload() { state.selectedFile = null; state.parseResult = null; $("#uploadForm")?.reset(); if ($("#filePreview")) $("#filePreview").hidden = true; if ($("#parseResult")) $("#parseResult").hidden = true; if ($("#sheetReview")) $("#sheetReview").hidden = true; if ($("#sheetList")) $("#sheetList").replaceChildren(); if ($("#dropzone")) $("#dropzone").hidden = false; if ($("#parseButton")) { $("#parseButton").disabled = true; $("#parseButton span").textContent = "开始解析"; delete $("#parseButton").dataset.stage; } } function updateParseButton() { const button = $("#parseButton"); if (button) button.disabled = !(state.selectedFile && $("#accountSelect").value); } function acceptFile(file) { if (!file) return; const extension = file.name.split(".").pop().toLowerCase(); if (!["xls", "xlsx"].includes(extension)) { showToast("文件格式不支持", "请选择银行导出的 .xls 或 .xlsx 文件", "danger"); return; } state.selectedFile = file; state.parseResult = null; $("#fileName").textContent = file.name; $("#fileMeta").textContent = `${(file.size / 1024).toFixed(1)} KB · 等待表头识别`; $("#filePreview").hidden = false; $("#dropzone").hidden = true; $("#parseResult").hidden = true; delete $("#parseButton").dataset.stage; updateParseButton(); } async function parseFile() { const formData = new FormData(); formData.append("file", state.selectedFile); const selectedAccount = $("#accountSelect")?.selectedOptions?.[0]; if (selectedAccount?.dataset.accountId) { formData.append("bank_account_id", selectedAccount.dataset.accountId); } let result; let parsed = false; try { const response = await fetch("/api/parse", { method: "POST", body: formData }); if (response.status === 401 || response.status === 403) { window.location.href = "index.html"; return; } result = await response.json(); parsed = response.ok && ["parsed", "duplicate"].includes(result.status); } catch { result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" }; } state.parseResult = result; renderParseResult(result, parsed); } function renderParseResult(result, parsed) { const panel = $("#parseResult"); if (!panel) return; const sheets = Array.isArray(result.sheets) ? result.sheets : []; const duplicated = result.status === "duplicate"; const opaque = duplicated && !sheets.length; panel.classList.toggle("info", parsed); panel.classList.toggle("warn", !parsed); const parseIcon = $("use", panel); if (parseIcon) parseIcon.setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert"); $("#parseTitle").textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板"; const pendingCount = sheets.filter((s) => s.outcome === "parsed" && s.review_status === "pending").length; const exceptionCount = sheets.filter((s) => s.outcome === "exception").length; const ignoredCount = sheets.filter((s) => s.outcome === "ignored" || s.review_status === "ignored").length; let summary; if (opaque) { summary = "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。"; } else if (sheets.length) { summary = `${sheets.length} 个工作表:${pendingCount} 个待确认${exceptionCount ? `、${exceptionCount} 个异常` : ""}${ignoredCount ? `、${ignoredCount} 个忽略` : ""}。解析成功不等于业务确认。`; } else { summary = `${result.message || ""} 系统不会猜测模板或自动入账。`; } $("#parseSummary").textContent = summary; panel.hidden = false; renderSheetList(sheets, result.batch_id); const button = $("#parseButton"); button.dataset.stage = "confirm"; button.disabled = false; if (!parsed) { button.querySelector("span").textContent = "关闭"; } else if (opaque) { button.querySelector("span").textContent = "完成"; } else if (sheets.length) { button.querySelector("span").textContent = pendingCount ? `确认全部(${pendingCount} 个待确认)` : "完成"; } else { button.querySelector("span").textContent = "完成"; } } function sheetStatusMeta(sheet) { if (sheet.review_status === "confirmed") return { className: "success", label: "已确认" }; if (sheet.review_status === "ignored") return { className: "neutral", label: "已忽略" }; if (sheet.outcome === "exception") return { className: "danger", label: "异常待处理" }; if (sheet.outcome === "ignored") return { className: "neutral", label: "空表忽略" }; return { className: "warning", label: "待确认" }; } function renderSheetList(sheets, batchId) { const wrap = $("#sheetReview"); const list = $("#sheetList"); if (!wrap || !list || !sheets.length) { if (wrap) wrap.hidden = true; return; } wrap.hidden = false; list.replaceChildren(...sheets.map((sheet) => buildSheetItem(sheet, batchId))); } function buildSheetItem(sheet, batchId) { const item = document.createElement("div"); item.className = "list-row"; const meta = sheetStatusMeta(sheet); const main = document.createElement("div"); main.className = "lr-main"; const title = document.createElement("div"); title.className = "lr-title"; title.textContent = sheet.sheet_name; main.append(title); const details = document.createElement("div"); details.className = "lr-sub"; if (sheet.outcome === "parsed" && sheet.bank) { const period = sheet.period_start ? ` · ${sheet.period_start}—${sheet.period_end}` : ""; details.textContent = `${sheet.bank} · ${sheet.transactions} 条明细${period}`; } else if (sheet.message) { details.textContent = sheet.message; } else { details.textContent = "空工作表。"; } if (sheet.review_reason) { details.textContent += ` · 原因:${sheet.review_reason}`; } main.append(details); const badge = document.createElement("span"); badge.className = `pill ${pillClass(meta.className)}`; badge.textContent = meta.label; item.append(main, badge); if (sheet.review_status === "pending") { const actions = document.createElement("div"); actions.className = "lr-side"; actions.style.cssText = "display:flex;gap:6px;flex:none;"; if (sheet.outcome === "parsed") { const confirmButton = document.createElement("button"); confirmButton.type = "button"; confirmButton.className = "btn btn-sm btn-primary"; confirmButton.textContent = "确认"; confirmButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "confirm")); actions.append(confirmButton); } const ignoreButton = document.createElement("button"); ignoreButton.type = "button"; ignoreButton.className = "btn btn-sm"; ignoreButton.textContent = "忽略"; ignoreButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "ignore")); actions.append(ignoreButton); item.append(actions); } return item; } async function sheetReviewAction(batchId, sheetName, decision) { const payload = { sheets: [sheetName] }; if (decision === "ignore") { const reason = (window.prompt("请填写忽略原因(必填):", "") || "").trim(); if (!reason) { showToast("忽略未提交", "必须填写忽略原因", "warn"); return; } payload.reason = reason; } const response = await fetch(`/api/batches/${batchId}/${decision}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("操作失败", result?.message || "请稍后重试", "danger"); return; } showToast(decision === "confirm" ? "工作表已确认" : "工作表已忽略", `${sheetName}`, "success"); await refreshAfterSheetAction(result, batchId); } async function refreshAfterSheetAction(result, batchId) { if (Array.isArray(result.sheets)) renderSheetList(result.sheets, batchId); await loadImportBatches(); const button = $("#parseButton"); if (!button) return; const pending = (result.sheets || []).filter((s) => s.outcome === "parsed" && s.review_status === "pending").length; if (pending === 0 && (result.sheets || []).length) { button.querySelector("span").textContent = "完成"; button.dataset.stage = "done"; } else { button.querySelector("span").textContent = `确认全部(${pending} 个待确认)`; } } async function confirmImport() { const result = state.parseResult; const batchId = result?.batch_id; const sheets = Array.isArray(result?.sheets) ? result.sheets : []; const pending = sheets .filter((s) => s.outcome === "parsed" && s.review_status === "pending") .map((s) => s.sheet_name); if (!pending.length) { resetUpload(); await loadImportBatches(); showView("upload"); return; } const button = $("#parseButton"); button.disabled = true; button.querySelector("span").textContent = "正在确认..."; const response = await fetch(`/api/batches/${batchId}/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sheets: pending }), }).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const outcome = await response?.json().catch(() => ({})); if (!response || !response.ok) { button.disabled = false; button.querySelector("span").textContent = "确认失败,点击重试"; showToast("确认失败", outcome?.message || "请稍后重试", "danger"); return; } button.disabled = false; if (Array.isArray(outcome.sheets)) renderSheetList(outcome.sheets, batchId); await loadImportBatches(); resetUpload(); showView("upload"); showToast("流水已确认", `${outcome.updated.length} 个工作表已确认;未确认的工作表不参与计算`, "success"); } function submitImportException() { resetUpload(); showView("upload"); showToast("解析异常未入账", `${state.selectedFile?.name || "该文件"} 不会进入匹配与计算,请核对模板后重新导出`, "warn"); } function renderBatchRow(batch) { const row = document.createElement("tr"); row.dataset.batchId = batch.id; const idCell = document.createElement("td"); const id = document.createElement("span"); id.className = "cell-main num"; id.textContent = `IMP-${String(batch.id).padStart(6, "0")}`; const file = document.createElement("span"); file.className = "cell-sub"; file.textContent = batch.original_filename || ""; idCell.append(id, file); const bank = document.createElement("td"); bank.textContent = batch.bank_name || "—"; const period = document.createElement("td"); period.className = "num"; period.textContent = batch.period_start && batch.period_end ? `${batch.period_start} ~ ${batch.period_end}` : "—"; const count = document.createElement("td"); count.className = "num-col"; count.textContent = `${batch.confirmed_transactions ?? 0}`; const coverage = document.createElement("td"); const coverageStatus = batch.status === "exception" ? { className: "danger", label: "未导入" } : batch.pending_sheets > 0 ? { className: "warning", label: `${batch.pending_sheets} 个待确认` } : batch.confirmed_sheets > 0 ? { className: "success", label: "已确认" } : { className: "neutral", label: "待处理" }; coverage.innerHTML = `${coverageStatus.label}`; const parseState = document.createElement("td"); const statusParts = []; if (batch.exception_sheets > 0) statusParts.push(`${batch.exception_sheets} 个异常`); if (batch.ignored_sheets > 0) statusParts.push(`${batch.ignored_sheets} 个忽略`); if (statusParts.length) { parseState.innerHTML = `${statusParts.join("、")}`; } else { parseState.innerHTML = '解析成功'; } const time = document.createElement("td"); time.className = "meta"; time.textContent = String(batch.created_at || "").slice(0, 16).replace("T", " "); const action = document.createElement("td"); action.innerHTML = ''; row.append(idCell, bank, period, count, coverage, parseState, time, action); return row; } async function loadImportBatches() { const tbody = $("#importRows"); if (!tbody) return; showTableLoading(tbody, 8); const response = await fetch("/api/batches").catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } if (!response?.ok) { showTableError(tbody, 8); return; } const result = await response?.json().catch(() => ({})); const batches = Array.isArray(result?.batches) ? result.batches : []; state.batches = batches; tbody.replaceChildren(...batches.map(renderBatchRow)); const foot = $("#importFoot"); if (foot) foot.textContent = batches.length ? `共 ${batches.length} 个批次` : "暂无批次"; } function formatWorkspaceAmount(amount, currency) { const n = Number(amount); const text = Number.isFinite(n) ? n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(amount ?? "—"); return `${currency || "CNY"} ${text}`; } function applyCompanyWorkspace(payload) { const events = Array.isArray(payload?.unilateral_events) ? payload.unilateral_events : []; const pending = Number(payload?.pending_unilateral ?? events.length) || 0; const total = Number(payload?.pending_total ?? pending) || 0; state.workspace = { pending_unilateral: pending, pending_total: total, unilateral_events: events }; const cta = $("#workspaceUnilateralCta"); if (cta) { cta.textContent = pending ? `去确认单边流水 (${pending})` : "单边流水已全部确认"; cta.classList.toggle("btn-primary", pending > 0); } const status = $("#workspacePendingStatus"); if (status) { status.textContent = total ? `${total} 项待处理` : "已完成"; status.className = `pill ${total ? "pill-warn" : "pill-success"}`; } const sub = $("#workspaceTodoSub"); if (sub) sub.textContent = total ? `权威待确认单边流水 ${pending} 笔` : "本月单边流水待办已清空"; const list = $("#workspaceTodoList"); if (list) { list.replaceChildren(); if (!events.length) { const empty = document.createElement("div"); empty.className = "empty"; empty.style.padding = "18px 16px"; empty.innerHTML = '
暂无待确认单边流水
刷新或重登后仍以服务端权威状态为准
'; list.append(empty); } else { events.forEach((event) => { const row = document.createElement("div"); row.className = "list-row"; row.dataset.taskType = "match"; row.dataset.eventId = String(event.event_id); row.innerHTML = ` 阻断
确认单边流水 · ${formatWorkspaceAmount(event.amount, event.currency)}
${event.counterparty_company_name || "对方待指定"} · ${event.effective_at || "—"}
`; list.append(row); }); } } const foot = $("#workspaceTodoFoot"); if (foot) { foot.innerHTML = total ? `处理完 ${total} 笔单边流水后,工作台数字与列表将同步归零` : "单边流水待办已完成"; } const flowState = $("#workspaceConfirmState"); if (flowState) flowState.textContent = pending ? `待处理 ${pending} 笔` : "已完成"; const flowMeta = $("#workspaceConfirmMeta"); if (flowMeta) { flowMeta.textContent = pending ? `单边流水 ${pending} 笔待确认` : "已全部确认,等待集团结账"; } // 完成态必须切到 success 绿(.flow-step.done),待确认保留 warn 黄(.doing) const flowStep = flowState?.closest(".flow-step"); if (flowStep) { flowStep.classList.toggle("doing", pending > 0); flowStep.classList.toggle("done", pending === 0); } const flowSub = $("#workspaceFlowSub"); if (flowSub) { flowSub.textContent = pending ? "当前停在第 3 步「往来确认」,完成后即可等待集团结账" : "第 3 步「往来确认」已完成,等待集团复核与结账"; } const countMatch = $("#count-match"); if (countMatch) countMatch.textContent = String(pending); const noticeTitle = $("#notice-title"); const noticeBody = $("#notice-body"); const notice = $("#blocking-notice"); if (noticeTitle) { noticeTitle.textContent = pending ? `${pending} 项单边流水待确认,是结账阻断项` : "单边流水已全部确认完成"; } if (noticeBody) { noticeBody.textContent = pending ? `工作台「去确认单边流水」与「本月待办」均读取同一权威集合(${pending} 笔)。确认成功后立即同步减一。` : "本公司单边流水待办已清空;刷新或重新登录后仍为 0。"; } if (notice) { notice.classList.toggle("warn", pending > 0); notice.classList.toggle("success", pending === 0); } const badge = $('.side-nav a[data-view="reconcile"] .nav-badge'); if (badge) { badge.textContent = pending; badge.style.display = pending ? "" : "none"; } } async function loadCompanyWorkspace() { const response = await fetch("/api/company/workspace").catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return null; } const result = await response?.json().catch(() => null); if (!response?.ok || !result || result.status !== "ok") { applyCompanyWorkspace({ pending_unilateral: 0, pending_total: 0, unilateral_events: [] }); showToast("工作台待办读取失败", result?.message || "请稍后重试", "danger"); return null; } applyCompanyWorkspace(result); return result; } function renderUnilateralMatchCard(event, peers) { const card = document.createElement("div"); card.className = "card"; card.dataset.matchCard = "true"; card.dataset.eventId = String(event.event_id); card.dataset.revision = String(event.revision ?? ""); const amountText = formatWorkspaceAmount(event.amount, event.currency); const peerName = event.counterparty_company_name || "对方待指定"; const options = peers .filter((p) => Number(p.id) !== Number(event.own_company_id)) .map((p) => { const selected = Number(p.id) === Number(event.counterparty_company_id) ? " selected" : ""; return ``; }) .join(""); card.innerHTML = `
${event.effective_at || "—"} · ${amountText} 对方:${peerName} 单边
事件编号
${event.event_id}
状态
${event.status || event.classification || "待确认"}
金额
${amountText}
确认后按现有单边确认规则锁定对方参与方;工作台数字以服务端权威状态重拉。
查看本方流水
`; const select = $("[data-counterparty-select]", card); const button = $("[data-match-confirm]", card); const syncEnabled = () => { if (button) button.disabled = !select?.value; }; select?.addEventListener("change", syncEnabled); syncEnabled(); button?.addEventListener("click", async () => { if (!select?.value || button.dataset.busy === "1") return; button.dataset.busy = "1"; button.disabled = true; const requestKey = `company-confirm-${event.event_id}-${event.revision}-${select.value}`; const response = await fetch(`/api/company/transfer-events/${event.event_id}/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ expected_revision: event.revision, request_key: requestKey, counterparty_company_id: Number(select.value), reason: "公司端确认单边流水", }), }).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response?.ok) { button.dataset.busy = "0"; syncEnabled(); showToast("确认失败", result?.message || "请刷新后重试", "danger"); // 失败不得本地误减:重拉权威状态 await loadCompanyWorkspace(); await renderReconcileMatchStack(); return; } const matchedList = $("#matched-list"); const matchedSummary = $("#matched-summary"); if (matchedList) { const row = document.createElement("div"); row.className = "list-row"; row.innerHTML = `
${amountText} · 已确认对方 ${select.selectedOptions[0]?.textContent || ""}
已匹配`; matchedList.append(row); if (matchedSummary) matchedSummary.style.display = ""; } if (result.workspace) applyCompanyWorkspace(result.workspace); else await loadCompanyWorkspace(); await renderReconcileMatchStack(); showToast("匹配已确认", "工作台待办已按权威状态同步", "success"); }); return card; } async function loadCompanyPeerOptions() { if (Array.isArray(state.companyPeers) && state.companyPeers.length) return state.companyPeers; const response = await fetch("/api/company/companies").catch(() => null); const result = await response?.json().catch(() => null); const peers = Array.isArray(result?.companies) ? result.companies : []; state.companyPeers = peers; return peers; } async function renderReconcileMatchStack() { const stack = $("#match-stack"); if (!stack) return; const events = state.workspace?.unilateral_events || []; const peers = await loadCompanyPeerOptions(); stack.replaceChildren(); if (!events.length) { const empty = document.createElement("div"); empty.className = "empty"; empty.innerHTML = '
单边流水已全部匹配
确认结果已同步至工作台权威待办
'; stack.append(empty); return; } events.forEach((event) => stack.append(renderUnilateralMatchCard(event, peers))); } function initReconcile() { const subjectRows = $$("[data-subject-row]"); let pendingSubject = subjectRows.length; const countSubject = $("#count-subject"); if (countSubject) countSubject.textContent = pendingSubject; $$(".subject-confirm-btn").forEach((button) => { button.addEventListener("click", () => { const row = button.closest("[data-subject-row]"); const select = $("select", row); const subject = select.value; select.disabled = true; button.disabled = true; button.textContent = "已确认"; const statusCell = $(".subject-status", row); statusCell.innerHTML = `已确认 · ${subject}`; pendingSubject -= 1; if (countSubject) countSubject.textContent = pendingSubject; showToast("科目已确认", "科目确认仍为页面演示,不计入工作台权威待办", "success"); }); }); const tabMatch = $("#tab-match"); const tabSubject = $("#tab-subject"); const panelMatch = $("#panel-match"); const panelSubject = $("#panel-subject"); function switchTab(which) { tabMatch?.classList.toggle("active", which === "match"); tabSubject?.classList.toggle("active", which === "subject"); tabMatch?.setAttribute("aria-pressed", String(which === "match")); tabSubject?.setAttribute("aria-pressed", String(which === "subject")); if (panelMatch) panelMatch.style.display = which === "match" ? "" : "none"; if (panelSubject) panelSubject.style.display = which === "subject" ? "" : "none"; } tabMatch?.addEventListener("click", () => switchTab("match")); tabSubject?.addEventListener("click", () => switchTab("subject")); } function renderCompanyNoticeRow(item) { const row = document.createElement("div"); row.className = "list-row"; row.dataset.status = item.status_ui; row.dataset.reminderId = String(item.id); const titleWeight = item.status_ui === "unread" ? "650" : "400"; const titleColor = item.status_ui === "unread" ? "var(--fg)" : "var(--muted)"; const sourceTag = item.source === "auto" ? '系统' : '管理员'; const actionLink = item.action_link; let side = ""; if (item.status_ui === "unread") { side = ''; } else if (item.status_ui === "doing" && actionLink) { side = ``; } else if (item.status_ui === "doing") { side = ''; } row.innerHTML = `${reminderStatusLabel(item.status_ui)}` + sourceTag + `
${item.title}
` + `
${formatReminderTime(item.last_sent_at)} · ${item.content}
` + `
${side}
`; return row; } function initNotifications() { const tabs = $("#notice-tabs"); const list = $("#notice-list"); if (!tabs || !list) return; const emptyBox = $("#notice-empty"); let currentFilter = "all"; let rows = []; function refreshCounts(stats, unreadCount) { const c = stats || { all: rows.length, unread: 0, doing: 0, done: 0 }; if (!stats) { c.all = rows.length; rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; }); } else { c.all = rows.length; } $("#count-all").textContent = c.all; $("#count-unread").textContent = c.unread; $("#count-doing").textContent = c.doing; $("#count-done").textContent = c.done; const navBadge = $('.side-nav a[data-view="notifications"] .nav-badge'); const unread = unreadCount ?? c.unread; if (navBadge) { navBadge.textContent = unread; navBadge.style.display = unread ? "" : "none"; } const wsLink = $("#workspace-notice-link"); if (wsLink) wsLink.textContent = unread ? `全部通知 (${c.all}) →` : "全部通知 →"; } function applyFilter() { let visible = 0; rows.forEach((row) => { const show = currentFilter === "all" || row.getAttribute("data-status") === currentFilter; row.style.display = show ? "" : "none"; if (show) visible += 1; }); if (emptyBox) emptyBox.style.display = visible === 0 ? "" : "none"; } async function loadReminders() { const response = await fetch("/api/company/reminders").catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response?.ok) return; const items = result.reminders || []; list.replaceChildren(...items.map((item) => renderCompanyNoticeRow(item))); rows = $$(".list-row", list); refreshCounts(result.stats, result.unread_count); applyFilter(); const wsList = $("#workspace-notice-list"); if (wsList) { const preview = items.slice(0, 3); if (!preview.length) { wsList.innerHTML = '
暂无通知
'; } else { wsList.replaceChildren(...preview.map((item) => { const row = document.createElement("div"); row.className = "list-row"; row.innerHTML = `${item.display_type}` + `
${item.title}
${item.content.slice(0, 48)}
` + `${formatReminderTime(item.last_sent_at).slice(5, 10)}`; return row; })); } } } tabs.addEventListener("click", (event) => { const btn = event.target.closest("button[data-filter]"); if (!btn) return; $$("button", tabs).forEach((b) => { b.classList.remove("active"); b.setAttribute("aria-pressed", "false"); }); btn.classList.add("active"); btn.setAttribute("aria-pressed", "true"); currentFilter = btn.dataset.filter; applyFilter(); }); list.addEventListener("click", async (event) => { const viewLink = event.target.closest("[data-view-link]"); if (viewLink && list.contains(viewLink)) { event.preventDefault(); showView(viewLink.dataset.viewLink); return; } const readBtn = event.target.closest(".btn-mark-read"); const doneBtn = event.target.closest(".btn-mark-done"); const btn = readBtn || doneBtn; if (!btn) return; const row = btn.closest(".list-row"); const id = row?.dataset.reminderId; if (!id) return; const action = readBtn ? "acknowledge" : "resolve"; const response = await fetch(`/api/company/reminders/${id}/${action}`, { method: "POST" }).catch(() => null); if (response?.status === 401) { window.location.href = "index.html"; return; } if (!response?.ok) return; await loadReminders(); }); $("#mark-all-read")?.addEventListener("click", async () => { const unreadRows = rows.filter((row) => row.getAttribute("data-status") === "unread"); for (const row of unreadRows) { const id = row.dataset.reminderId; await fetch(`/api/company/reminders/${id}/acknowledge`, { method: "POST" }).catch(() => null); } await loadReminders(); showToast("通知已全部标为已读", "", "success"); }); loadReminders(); } function openAccountDetail(account) { const meta = companyAccountMeta(account.status); $("#ad-title").textContent = `${account.bank_name || ""} · 尾号 ${accountTail(account.account_number_masked)}`; $("#ad-sub").textContent = "本公司 · 登记账户明细"; $("#ad-bank").textContent = account.bank_name || "—"; $("#ad-tail").textContent = `尾号 ${accountTail(account.account_number_masked)}`; $("#ad-type").textContent = account.account_type || "—"; $("#ad-branch").textContent = account.account_name || "—"; $("#ad-reg").textContent = String(account.created_at || "").slice(0, 10) || "—"; $("#ad-status").textContent = meta.status.label; $("#ad-audit").textContent = meta.audit.label; $("#ad-purpose").textContent = "—"; $("#ad-effective").textContent = account.effective_from || "待审核确定"; $("#ad-range").textContent = account.usable ? "尚未上传" : "—"; $("#ad-reason").textContent = account.status === "returned" && account.review_reason ? account.review_reason : "—"; $("#modal-account-detail")?.classList.add("open"); } function yuanToWan(value) { const n = Number(value); if (!Number.isFinite(n)) return null; return n / 10000; } function formatWanHtml(value, { signed = false } = {}) { const wan = yuanToWan(value); if (wan === null) return "—"; const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); if (!signed) return `${abs}万元`; const sign = wan > 0 ? "+" : wan < 0 ? "−" : ""; return `${sign}${abs}万元`; } function formatWanText(value, { signed = false } = {}) { const wan = yuanToWan(value); if (wan === null) return "—"; const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); if (!signed) return `${abs} 万元`; const sign = wan > 0 ? "+" : wan < 0 ? "−" : ""; return `${sign}${abs} 万元`; } function netDirectionMeta(netValue, netDirection) { const wan = yuanToWan(netValue); if (wan === null || wan === 0 || !netDirection) { return { label: "持平", className: "flat", signedClass: "" }; } if (netDirection === "receivable" || wan > 0) { return { label: "应收", className: "recv", signedClass: "pos" }; } return { label: "应付", className: "pay", signedClass: "neg" }; } function netLabelForWindow(windowInfo) { return windowInfo?.has_opening ? "期末净往来" : "期间净变动"; } function showTransfersOverviewLayer() { const overview = $("#transfersOverviewLayer"); const detail = $("#transfersDetailLayer"); if (overview) overview.hidden = false; if (detail) detail.hidden = true; } function showTransfersDetailLayer() { const overview = $("#transfersOverviewLayer"); const detail = $("#transfersDetailLayer"); if (overview) overview.hidden = true; if (detail) detail.hidden = false; } function setTransfersUiState(mode) { ["transfersLoading", "transfersError", "transfersEmpty", "transfersData"].forEach((id) => { const el = $(`#${id}`); if (el) el.hidden = id !== mode; }); } function applyTransfersNavBadge(pendingCount) { const badge = $("#transfersNavBadge") || $('.side-nav a[data-view="transfers"] .nav-badge'); if (!badge) return; const n = Number(pendingCount) || 0; badge.textContent = String(n); badge.hidden = n <= 0; if (n <= 0) badge.setAttribute("hidden", ""); else badge.removeAttribute("hidden"); } function renderWorkspaceTransfersCard(summary) { const sub = $("#workspaceTransfersSub"); if (!sub || !summary) return; const win = summary.window || {}; const confirmed = summary.confirmed || {}; const pending = summary.pending || {}; const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction); sub.textContent = `${win.start || "—"} 至 ${win.end || "—"} · 集团内公司间 · 单位:万元`; const inflow = $("#wsTfIn"); const outflow = $("#wsTfOut"); const net = $("#wsTfNet"); const pendingEl = $("#wsTfPending"); const netLabel = $("#wsTfNetLabel"); if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true }); if (outflow) { outflow.innerHTML = formatWanHtml( confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null, { signed: true }, ); } if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`; if (net) { net.className = `ms-value ${netMeta.signedClass}`.trim(); net.innerHTML = formatWanHtml(confirmed.net_change, { signed: true }); } if (pendingEl) { const pCount = Number(pending.count) || 0; pendingEl.innerHTML = pCount ? `${formatWanHtml(pending.amount_total)} · ${pCount} 笔` : `0.00万元 · 0 笔`; } } async function loadTransfersSummary({ asOf } = {}) { if (!$("#transfersOverviewLayer")) return null; setTransfersUiState("transfersLoading"); const params = new URLSearchParams(); if (asOf) params.set("as_of", asOf); const qs = params.toString(); const response = await fetch(`/api/company/intercompany/summary${qs ? `?${qs}` : ""}`).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return null; } const result = await response?.json().catch(() => null); if (!response?.ok || !result || result.status !== "ok") { setTransfersUiState("transfersError"); const body = $("#transfersErrorBody"); if (body) body.textContent = result?.message || "服务器连接异常。为避免误读,本页不展示任何金额。"; return null; } state.transfersSummary = result; applyTransfersNavBadge(result.pending?.count); renderWorkspaceTransfersCard(result); renderTransfersOverview(result); return result; } function renderTransfersOverview(summary) { const win = summary.window || {}; const confirmed = summary.confirmed || {}; const pending = summary.pending || {}; const cps = Array.isArray(summary.counterparties) ? summary.counterparties : []; const ownName = summary.own_company?.name || "本公司"; const pageSub = $("#transfersPageSub"); if (pageSub) { pageSub.textContent = `${ownName} · 统计区间 ${win.start || "—"} 至 ${win.end || "—"},仅含集团内公司间转账(HEL-169 口径)。单位:万元。`; } const goConfirm = $("#transfersGoConfirm"); const pCount = Number(pending.count) || 0; if (goConfirm) { goConfirm.hidden = pCount <= 0; goConfirm.textContent = pCount ? `去确认待确认 ${pCount} 笔` : "去确认待确认"; } const hasAny = cps.length > 0 || Number(confirmed.outflow_count || 0) > 0 || Number(confirmed.inflow_count || 0) > 0 || pCount > 0; if (!hasAny) { setTransfersUiState("transfersEmpty"); const emptyStats = $("#transfersEmptyStats"); if (emptyStats) { emptyStats.innerHTML = `
往来公司数
0
${win.start || "—"} ~ ${win.end || "—"}
本期转入 · 流入
0.00万元
本期转出 · 流出
0.00万元
${netLabelForWindow(win)}
0.00万元
`; } return; } setTransfersUiState("transfersData"); const companies = $("#tfStatCompanies"); const inflow = $("#tfStatIn"); const outflow = $("#tfStatOut"); const net = $("#tfStatNet"); const netTitle = $("#tfStatNetTitle"); const netFoot = $("#tfStatNetFoot"); const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction); if (companies) companies.innerHTML = `${cps.length}`; if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true }); if (outflow) { const wan = yuanToWan(confirmed.outflow_total); const abs = wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); outflow.innerHTML = wan === null ? "—" : `−${abs}万元`; } if (netTitle) netTitle.textContent = netLabelForWindow(win); if (net) { const tag = netMeta.label !== "持平" ? ` ${netMeta.label}` : ""; net.innerHTML = `${formatWanHtml(confirmed.net_change, { signed: true })}${tag}`; } if (netFoot) { netFoot.textContent = win.has_opening ? "期末 = 期初 + 已确认转出 − 已确认转入" : "起算日未就绪 · 展示期间净变动(转出 − 转入),不得当作期末余额"; } const confirmedCount = (Number(confirmed.outflow_count) || 0) + (Number(confirmed.inflow_count) || 0); const tfConfirmedCount = $("#tfConfirmedCount"); const tfConfirmedNet = $("#tfConfirmedNet"); const tfPendingCount = $("#tfPendingCount"); const tfPendingAmount = $("#tfPendingAmount"); if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount} 笔`; if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWanHtml(confirmed.net_change, { signed: true }); const openingCard = $("#tfStatOpeningCard"); const endingCard = $("#tfStatEndingCard"); const openingEl = $("#tfStatOpening"); const endingEl = $("#tfStatEnding"); if (win.has_opening) { if (openingCard) openingCard.hidden = false; if (endingCard) endingCard.hidden = false; if (openingEl) openingEl.innerHTML = formatWanHtml(win.opening, { signed: true }); if (endingEl) endingEl.innerHTML = formatWanHtml(win.ending, { signed: true }); } else { if (openingCard) openingCard.hidden = true; if (endingCard) endingCard.hidden = true; } if (tfPendingCount) tfPendingCount.textContent = `${pCount} 笔`; if (tfPendingAmount) tfPendingAmount.innerHTML = formatWanHtml(pending.amount_total || 0); const tbody = $("#transfersCpBody"); const tfoot = $("#transfersCpFoot"); if (!tbody) return; if (!cps.length) { tbody.innerHTML = `
暂无对方公司汇总
`; if (tfoot) tfoot.innerHTML = ""; return; } tbody.innerHTML = cps.map((cp) => { const meta = netDirectionMeta(cp.net, Number(cp.net) > 0 ? "receivable" : Number(cp.net) < 0 ? "payable" : null); const pendingN = Number(cp.pending_count) || 0; const pendingPill = pendingN ? `${pendingN} 笔待确认` : `全部已确认`; const last = cp.last_effective_at ? String(cp.last_effective_at).slice(0, 10) : "—"; const inWan = yuanToWan(cp.confirmed_inflow); const outWan = yuanToWan(cp.confirmed_outflow); const netWan = yuanToWan(cp.net); const fmtAbs = (wan) => wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const inText = inWan === null ? "—" : `+${fmtAbs(inWan)}`; const outText = outWan === null ? "—" : `−${fmtAbs(outWan)}`; const netText = netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "−" : ""}${fmtAbs(netWan)}`; return ` ${cp.company_name || "—"} ${inText} ${outText} ${netText} ${meta.label} ${pendingPill} ${last} `; }).join(""); if (tfoot) { const totalPending = cps.reduce((s, cp) => s + (Number(cp.pending_count) || 0), 0); const inWan = yuanToWan(confirmed.inflow_total); const outWan = yuanToWan(confirmed.outflow_total); const netWan = yuanToWan(confirmed.net_change); const fmtAbs = (wan) => wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); tfoot.innerHTML = ` 合计 ${inWan === null ? "—" : `+${fmtAbs(inWan)}`} ${outWan === null ? "—" : `−${fmtAbs(outWan)}`} ${netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "−" : ""}${fmtAbs(netWan)}`} ${netMeta.label} ${totalPending ? `${totalPending} 笔待确认` : `全部已确认`} `; } } function transfersEventQueryParams({ includeCursor = false } = {}) { const detail = state.transfersDetail || {}; const filters = detail.filters || {}; const params = new URLSearchParams(); if (detail.company_id) params.set("counterparty_id", String(detail.company_id)); if (filters.from) params.set("from", filters.from); if (filters.to) params.set("to", filters.to); if (filters.direction) params.set("direction", filters.direction); if (filters.state) params.set("state", filters.state); params.set("limit", "50"); if (includeCursor && detail.nextCursor) params.set("cursor", detail.nextCursor); return params; } async function openTransfersDetail(companyId, companyName, { keepFilters = false } = {}) { const summary = state.transfersSummary; const win = summary?.window || {}; const existing = state.transfersDetail; const filters = keepFilters && existing?.filters ? { ...existing.filters } : { from: win.start || "", to: win.end || "", direction: "", state: "", }; state.transfersDetail = { company_id: Number(companyId), company_name: companyName || "对方公司", filters, nextCursor: null, events: [], }; state.transfersKeepDetail = true; showTransfersDetailLayer(); const title = $("#currentViewName"); if (title) title.textContent = `转账往来 / ${state.transfersDetail.company_name}`; $("#transfersDetailTitle").textContent = state.transfersDetail.company_name; $("#tfFilterFrom").value = filters.from || ""; $("#tfFilterTo").value = filters.to || ""; $("#tfFilterDirection").value = filters.direction || ""; $("#tfFilterState").value = filters.state || ""; const cp = (summary?.counterparties || []).find((c) => Number(c.company_id) === Number(companyId)); const pendingN = Number(cp?.pending_count) || 0; $("#tfDetailCount").innerHTML = `—`; $("#tfDetailCountFoot").textContent = pendingN ? `其中待确认 ${pendingN} 笔(汇总)` : "按筛选加载明细"; if (cp) { $("#tfDetailIn").innerHTML = formatWanHtml(cp.confirmed_inflow, { signed: true }); const wan = yuanToWan(cp.confirmed_outflow); $("#tfDetailOut").innerHTML = wan === null ? "—" : `−${Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}万元`; } if (state.currentView !== "transfers") { state.transfersKeepDetail = true; showView("transfers"); } await loadTransfersEvents({ reset: true }); } async function loadTransfersEvents({ reset = false } = {}) { if (!state.transfersDetail?.company_id) return; const tbody = $("#transfersEventBody"); const empty = $("#transfersEventEmpty"); const foot = $("#transfersEventFoot"); const moreBtn = $("#transfersLoadMore"); if (reset) { state.transfersDetail.nextCursor = null; state.transfersDetail.events = []; if (tbody) showTableLoading(tbody, 6); if (empty) empty.hidden = true; if (moreBtn) moreBtn.hidden = true; } const params = transfersEventQueryParams({ includeCursor: !reset && !!state.transfersDetail.nextCursor }); // Always need a distinguishing filter so HEL-176 path is used (counterparty_id is enough) const response = await fetch(`/api/company/intercompany/events?${params}`).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => null); if (!response?.ok || !result || result.status !== "ok") { if (tbody) showTableError(tbody, 6); showToast("明细加载失败", result?.message || "请稍后重试", "danger"); return; } const events = Array.isArray(result.events) ? result.events : []; if (reset) state.transfersDetail.events = events; else state.transfersDetail.events = [...(state.transfersDetail.events || []), ...events]; state.transfersDetail.nextCursor = result.next_cursor || null; state.transfersDetail.hasMore = !!result.has_more; renderTransfersEvents(); if (foot) foot.textContent = `已加载 ${state.transfersDetail.events.length} 笔${result.has_more ? " · 还有更多" : ""}`; if (moreBtn) moreBtn.hidden = !result.has_more; $("#tfDetailCount").innerHTML = `${state.transfersDetail.events.length}${result.has_more ? "+" : ""}`; } function transfersStateLabel(event) { if (event.state === "confirmed") { if (event.pairing === "paired") return { pill: "pill-success", text: "已确认 · 双边" }; if (event.locked) return { pill: "pill-success", text: "已确认 · 单边锁定" }; return { pill: "pill-success", text: "已确认" }; } return { pill: "pill-warn", text: "待确认" }; } function renderTransfersEvents() { const tbody = $("#transfersEventBody"); const empty = $("#transfersEventEmpty"); if (!tbody) return; const events = state.transfersDetail?.events || []; if (!events.length) { tbody.innerHTML = ""; if (empty) empty.hidden = false; return; } if (empty) empty.hidden = true; tbody.innerHTML = events.map((ev) => { const dir = ev.direction === "out" ? "转出" : ev.direction === "in" ? "转入" : "—"; const dirClass = ev.direction === "out" ? "amt-out" : ev.direction === "in" ? "amt-in" : ""; const wan = yuanToWan(ev.amount); const amt = wan === null ? "—" : `${ev.direction === "out" ? "−" : "+"}${Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const st = transfersStateLabel(ev); const date = String(ev.effective_at || "").slice(0, 10) || "—"; const pendingClass = ev.state === "pending" ? " is-pending" : ""; return ` ${date} ${ev.summary || "—"} ${dir}${ev.direction === "in" ? " ↓" : ev.direction === "out" ? " ↑" : ""} ${amt} ${st.text} `; }).join(""); } async function openTransferEvidence(eventId) { const drawer = $("#transferEvidenceDrawer"); if (!drawer) return; $("#tfEvTitle").textContent = "加载中…"; $("#tfEvDesc").textContent = ""; $("#tfEvFields").replaceChildren(); drawer.classList.add("is-open"); drawer.setAttribute("aria-hidden", "false"); const response = await fetch(`/api/company/transfer-events/${eventId}`).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => null); if (!response?.ok || !result || result.status !== "ok") { $("#tfEvTitle").textContent = "无法打开原始流水"; $("#tfEvDesc").textContent = result?.message || "事件不存在或无权查看"; return; } const event = result.event || {}; const obs = Array.isArray(event.observations) ? event.observations[0] : null; const cp = event.counterparty || {}; $("#tfEvTag").className = "pill pill-info"; $("#tfEvTag").textContent = "银行原始流水 · 只读"; $("#tfEvTitle").textContent = obs?.reference || `事件 #${event.event_id}`; $("#tfEvDesc").textContent = obs?.import_batch_id ? `导入批次 IMP-${String(obs.import_batch_id).padStart(6, "0")}${obs.original_filename ? ` · ${obs.original_filename}` : ""}` : "本方银行流水原文"; const income = obs?.income != null && Number(obs.income) !== 0; const amountText = obs ? `${Number(income ? obs.income : obs.expense).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元(${income ? "收 / 转入" : "付 / 转出"})` : `${event.amount || "—"} ${event.currency || "CNY"}`; const fields = [ ["本方账户", obs?.own_account_masked || "—"], ["交易时间", obs?.transaction_at || event.effective_at || "—"], ["方向", income ? "收入(转入)" : "支出(转出)"], ["金额", amountText], ["对方户名", obs?.counterparty_name || cp.company_name || "—"], ["对方账号", obs?.counterparty_account_masked || cp.account_number_masked || "—"], ["工作表", obs?.sheet_name || "—"], ["原始文件行号", obs?.source_row != null ? String(obs.source_row) : "—"], ["摘要", obs?.summary || event.reason || "—"], ["确认状态", event.pairing === "paired" ? "已确认 · 双边一致" : event.status || "—"], ]; const dl = $("#tfEvFields"); dl.replaceChildren(...fields.flatMap(([label, value]) => { const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); dd.className = "num"; dd.textContent = value; return [dt, dd]; })); $("[data-close-transfer-evidence]", drawer)?.focus(); } function closeTransferEvidence({ restoreFocus = true } = {}) { const drawer = $("#transferEvidenceDrawer"); if (!drawer) return; drawer.classList.remove("is-open"); drawer.setAttribute("aria-hidden", "true"); } function buildTransfersExportUrl() { const detail = state.transfersDetail; const summary = state.transfersSummary; const params = new URLSearchParams(); if (detail?.company_id) { params.set("counterparty_id", String(detail.company_id)); const f = detail.filters || {}; if (f.from) params.set("from", f.from); if (f.to) params.set("to", f.to); if (f.direction) params.set("direction", f.direction); } else if (summary?.window) { if (summary.window.start) params.set("from", summary.window.start); if (summary.window.end) params.set("to", summary.window.end); } const qs = params.toString(); return `/api/company/intercompany/export.csv${qs ? `?${qs}` : ""}`; } function initTransfers() { if (!$("[data-page='transfers']")) return; $("#transfersRetryBtn")?.addEventListener("click", () => loadTransfersSummary()); $("#transfersExportBtn")?.addEventListener("click", () => { window.location.href = buildTransfersExportUrl(); }); $("#transfersDetailExportBtn")?.addEventListener("click", () => { window.location.href = buildTransfersExportUrl(); }); $("#transfersBackBtn")?.addEventListener("click", () => { state.transfersDetail = null; showTransfersOverviewLayer(); const title = $("#currentViewName"); if (title) title.textContent = "转账往来"; if (!state.transfersSummary) loadTransfersSummary(); else renderTransfersOverview(state.transfersSummary); }); $("#transfersCpBody")?.addEventListener("click", (event) => { const row = event.target.closest("tr[data-cp-id]"); if (!row) return; openTransfersDetail(row.dataset.cpId, row.dataset.cpName); }); $("#transfersFilterForm")?.addEventListener("submit", (event) => { event.preventDefault(); if (!state.transfersDetail) return; state.transfersDetail.filters = { from: $("#tfFilterFrom")?.value || "", to: $("#tfFilterTo")?.value || "", direction: $("#tfFilterDirection")?.value || "", state: $("#tfFilterState")?.value || "", }; loadTransfersEvents({ reset: true }); }); const clearFilters = () => { if (!state.transfersDetail) return; const win = state.transfersSummary?.window || {}; state.transfersDetail.filters = { from: win.start || "", to: win.end || "", direction: "", state: "", }; $("#tfFilterFrom").value = state.transfersDetail.filters.from; $("#tfFilterTo").value = state.transfersDetail.filters.to; $("#tfFilterDirection").value = ""; $("#tfFilterState").value = ""; loadTransfersEvents({ reset: true }); }; $("#tfFilterReset")?.addEventListener("click", clearFilters); $("#tfEmptyClear")?.addEventListener("click", clearFilters); $("#transfersLoadMore")?.addEventListener("click", () => loadTransfersEvents({ reset: false })); $("#transfersEventBody")?.addEventListener("click", (event) => { const btn = event.target.closest("[data-transfer-evidence]"); if (!btn) return; openTransferEvidence(btn.dataset.transferEvidence); }); $$("[data-close-transfer-evidence]").forEach((btn) => btn.addEventListener("click", () => closeTransferEvidence())); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && $("#transferEvidenceDrawer")?.classList.contains("is-open")) { event.stopPropagation(); closeTransferEvidence(); } }); // 工作台概览与角标:与页面共用 summary loadTransfersSummary(); } function initCompany() { $("#openAttestationFromWorkspace")?.addEventListener("click", () => openAttestationDialog()); $("#openAttestationFromFlows")?.addEventListener("click", () => openAttestationDialog()); $$("[data-close-attestation]").forEach((btn) => btn.addEventListener("click", () => closeModal("attestationDialog"))); $("#attestationForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const payload = { bank_account_id: Number($("#att-account-id")?.value || 0), gap_start: $("#att-gap-start")?.value || "", gap_end: $("#att-gap-end")?.value || "", reason: $("#att-reason")?.value || "", evidence: $("#att-evidence")?.value || "", }; const response = await fetch("/api/company/no-business-attestations", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { showToast("提交失败", result?.message || "请稍后重试", "danger"); return; } closeModal("attestationDialog"); await loadCompanyCoverageGaps(); showToast("已提交无业务说明", "等待管理员审核,通过后仅关闭断档提醒", "success"); }); loadCompanyPeerSelect(); loadCompanyManualRecords(); loadCompanyAccounts(); loadImportBatches(); $$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close))); $$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); })); document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); }); $$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => showView("upload"))); // ── 流水导入:多步流程(选文件 → 解析 → 分 sheet 审核 → 确认) ── $("#accountSelect")?.addEventListener("change", updateParseButton); $("#fileInput")?.addEventListener("change", (event) => acceptFile(event.target.files[0])); $("#removeFile")?.addEventListener("click", () => { state.selectedFile = null; $("#fileInput").value = ""; $("#filePreview").hidden = true; $("#dropzone").hidden = false; $("#parseResult").hidden = true; updateParseButton(); }); const dropzone = $("#dropzone"); if (dropzone) { ["dragenter", "dragover"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add("dragover"); })); ["dragleave", "drop"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove("dragover"); })); dropzone.addEventListener("click", () => $("#fileInput").click()); dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); $("#fileInput").click(); } }); dropzone.addEventListener("drop", (event) => acceptFile(event.dataTransfer.files[0])); } $("#uploadForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const stage = $("#parseButton").dataset.stage; if (stage === "confirm" || stage === "done") { const result = state.parseResult; const parsed = result && ["parsed", "duplicate"].includes(result.status); if (parsed && stage === "confirm") { await confirmImport(); } else if (parsed) { resetUpload(); showView("upload"); await loadImportBatches(); } else { submitImportException(); } return; } $("#parseButton").disabled = true; $("#parseButton span").textContent = "正在识别表头..."; await parseFile(); }); // ── 导入批次详情弹窗 ── $("#importRows")?.addEventListener("click", (event) => { const btn = event.target.closest("[data-batch-view]"); if (!btn) return; const tr = btn.closest("tr"); const batch = (state.batches || []).find((b) => String(b.id) === String(tr?.dataset.batchId)); if (!batch) return; $("#mb-sub").textContent = `IMP-${String(batch.id).padStart(6, "0")} · ${batch.original_filename || ""}`; $("#mb-id").textContent = `IMP-${String(batch.id).padStart(6, "0")}`; $("#mb-bank").textContent = batch.bank_name || "—"; $("#mb-period").textContent = batch.period_start && batch.period_end ? `${batch.period_start} ~ ${batch.period_end}` : "—"; $("#mb-count").textContent = `${batch.confirmed_transactions ?? 0} 条`; $("#mb-cover").textContent = batch.status === "exception" ? "未导入" : batch.pending_sheets > 0 ? `${batch.pending_sheets} 个待确认` : batch.confirmed_sheets > 0 ? "已确认" : "待处理"; $("#mb-parse").textContent = batch.exception_sheets > 0 ? `${batch.exception_sheets} 个异常` : batch.ignored_sheets > 0 ? `${batch.ignored_sheets} 个忽略` : "解析成功"; $("#mb-time").textContent = String(batch.created_at || "").slice(0, 16).replace("T", " "); openModal("modal-batch"); }); // ── 手工记录:提交 + 撤回 ── $("#manualEntryForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const data = new FormData(form); const counterpartyId = Number(data.get("counterparty")); if (!counterpartyId) { showToast("请选择对方公司", "手工记录仅登记集团内公司往来", "warn"); return; } const source = String(data.get("sourceAccount") || ""); const funding = source.includes("个人过账") ? "personal_transit" : (source ? "approved_bank_account" : "other"); const response = await fetch("/api/company/manual-records", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ counterparty_company_id: counterpartyId, occurred_at: String(data.get("transactionDate") || ""), direction: String(data.get("direction")) === "收款" ? "incoming" : "outgoing", amount: String(data.get("amount") || ""), currency: "CNY", funding_source: funding === "approved_bank_account" ? "other" : funding, requested_subject: SUBJECT_LABEL_CODE[String(data.get("subject") || "")] || "other_receivable", request_key: `manual-${Date.now()}`, summary: String(data.get("summary") || "").trim(), reason: String(data.get("remark") || "").trim(), }), }).catch(() => null); const result = await response?.json().catch(() => ({})); if (!response?.ok) { if (!toastIfLocked(result)) showToast("提交失败", result?.message || "请稍后重试", "danger"); return; } form.reset(); await loadCompanyManualRecords(); showToast("手工记录已提交", "管理员复核前不会纳入公司间往来计算", "success"); }); let manualWithdrawRow = null; $("#manualRecordRows")?.addEventListener("click", (event) => { const btn = event.target.closest("button[data-action='withdraw']"); if (!btn) return; const tr = btn.closest("tr"); manualWithdrawRow = tr; const cells = tr.cells; $("#wd-date").textContent = cells[0]?.textContent.trim() || "—"; $("#wd-peer").textContent = cells[2]?.textContent.trim() || "—"; $("#wd-amount").textContent = cells[4]?.textContent.trim() || "—"; $("#wd-summary").textContent = cells[5]?.textContent.trim() || "—"; openModal("withdraw-modal"); }); $("#wd-confirm")?.addEventListener("click", () => { closeModal("withdraw-modal"); showToast("已提交记录不可在公司端撤回", "请联系管理员退回后重新登记", "warn"); }); // ── 转账往来(方案 A)── initTransfers(); // ── 往来确认 + 工作台权威待办 ── initReconcile(); (async () => { await loadCompanyWorkspace(); await loadCompanyCoverageGaps(); await renderReconcileMatchStack(); })(); // ── 通知 ── initNotifications(); // ── 银行账户 ── $("#openAccountDialog")?.addEventListener("click", () => openModal("accountDialog")); $("#account-tbody")?.addEventListener("click", (event) => { const btn = event.target.closest("[data-account-view]"); if (!btn) return; const tr = btn.closest("tr"); const account = (state.accounts || []).find((a) => String(a.id) === String(tr?.dataset.accountId)); if (!account) return; openAccountDetail(account); }); $("#accountForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const data = new FormData(form); const response = await fetch("/api/company/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bank_name: String(data.get("bank") || "").trim(), account_type: String(data.get("type") || ""), account_number: String(data.get("accountNumber") || ""), start_date: String(data.get("startDate") || ""), }), }).catch(() => null); if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { showToast("账户登记失败", result?.message || "请稍后重试", "danger"); return; } closeModal("accountDialog"); form.reset(); await loadCompanyAccounts(); showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success"); }); } if (portal === "entry") { initEntry(); } else { initAuthGuard().then((allowed) => { if (!allowed) return; initShell(); initFlowTools(); if (portal === "admin") { initPairQueries(); initAdmin(); } else { initCompany(); } }); }