Files
b27016d71d HEL-177: 公司端按方案 A 实现转账往来四层页面
工作台概览小卡 + 侧栏入口;总览/对方汇总/明细/原始流水抽屉接入 HEL-175/176 接口;无期初时展示期间净变动;模块内用 info 品蓝。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 13:45:19 +00:00

3701 lines
182 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $ = (selector, scope = document) => scope.querySelector(selector);
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
const portal = document.body.dataset.portal || "entry";
const viewNames = portal === "admin"
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", transfers: "转账往来", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
const storageKeys = {
manual: "ledger-demo-manual-records",
};
const accountStatusLabels = {
pending: "待复核",
active: "已启用",
returned: "已退回",
disabled: "已停用",
};
const state = {
currentView: portal === "admin" ? "dashboard" : "workspace",
selectedFile: null,
parseResult: null,
};
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
function animateView(view, { initial = false } = {}) {
if (!view || motionQuery.matches || typeof view.animate !== "function") return;
if (!initial) {
view.getAnimations().forEach((animation) => animation.cancel());
view.animate(
[{ opacity: 0.84, transform: "translateY(5px)" }, { opacity: 1, transform: "translateY(0)" }],
{ duration: 180, easing: "cubic-bezier(.22,1,.36,1)" },
);
return;
}
const selectors = [
".page-head",
".stat-card",
".card",
".notice",
".list-row",
".filters",
];
const elements = [...new Set(selectors.flatMap((selector) => [...view.querySelectorAll(selector)]))];
elements.forEach((element, index) => {
element.getAnimations().forEach((animation) => animation.cancel());
const keyframes = [
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
{ opacity: 1, transform: "translateY(0)" },
];
element.animate(keyframes, {
duration: 440,
delay: Math.min(index * 38, 260),
easing: "cubic-bezier(.22,1,.36,1)",
fill: "both",
});
});
}
function initMotion() {
if (motionQuery.matches) return;
animateView($(".app-view.is-active"), { initial: true });
$$(".side-nav a", $("#sidebar") || document).forEach((item, index) => {
item.animate(
[{ opacity: 0, transform: "translateX(-8px)" }, { opacity: 1, transform: "translateX(0)" }],
{ duration: 360, delay: 90 + index * 28, easing: "cubic-bezier(.22,1,.36,1)", fill: "both" },
);
});
}
function readStoredRecords(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || "[]");
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
function writeStoredRecords(key, records) {
try {
localStorage.setItem(key, JSON.stringify(records));
return true;
} catch {
showToast("本机演示数据保存失败", "请检查浏览器是否允许本地存储", "danger");
return false;
}
}
function recordStatus(status) {
if (["已启用", "已确认"].includes(status)) return { className: "success", label: status };
if (status === "已退回") return { className: "danger", label: status };
if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status };
return { className: "warning", label: status || "待复核" };
}
function accountStatusLabel(status) {
return accountStatusLabels[status] || "待复核";
}
function pillClass(statusClass) {
return { success: "pill-success", danger: "pill-danger", warning: "pill-warn", neutral: "pill-muted", info: "pill-info" }[statusClass] || "pill-muted";
}
function accountTail(masked) {
return String(masked || "").replace(/^\*+/, "");
}
function formatCurrency(value) {
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function showTableLoading(tbody, cols = 6) {
if (!tbody) return;
tbody.innerHTML = `<tr class="loading-row"><td colspan="${cols}">加载中…</td></tr>`;
}
function showTableError(tbody, cols = 6) {
if (!tbody) return;
tbody.innerHTML = `<tr class="loading-row"><td colspan="${cols}" style="color: var(--danger);">加载失败,请稍后重试</td></tr>`;
}
function showToast(title, detail = "", kind = "info") {
const region = $("#toastRegion");
if (!region) return;
const toast = document.createElement("div");
toast.className = `toast ${["success", "warn", "danger", "info"].includes(kind) ? kind : "info"}`;
toast.setAttribute("role", "status");
const dot = document.createElement("span");
dot.className = "t-dot";
const body = document.createElement("div");
body.className = "t-body";
const heading = document.createElement("div");
heading.className = "t-title";
heading.textContent = title;
body.append(heading);
if (detail) {
const description = document.createElement("div");
description.className = "t-detail";
description.textContent = detail;
body.append(description);
}
toast.append(dot, body);
region.append(toast);
window.setTimeout(() => {
toast.style.opacity = "0";
toast.style.transition = "opacity 0.2s ease";
window.setTimeout(() => toast.remove(), 200);
}, 3400);
}
function closeNavigation({ restoreFocus = false } = {}) {
const sidebar = $("#sidebar");
if (!sidebar) return;
const wasOpen = sidebar.classList.contains("is-open");
sidebar.classList.remove("is-open");
$$(".menu-button").forEach((button) => {
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "打开导航");
});
if (restoreFocus && wasOpen) $(".menu-button")?.focus();
}
function showView(view) {
if (!viewNames[view]) return;
const navigationWasOpen = $("#sidebar")?.classList.contains("is-open");
state.currentView = view;
$$(".app-view").forEach((page) => page.classList.toggle("is-active", page.dataset.page === view));
$$(".side-nav a[data-view]").forEach((item) => {
const active = item.dataset.view === view;
item.classList.toggle("active", active);
if (active) item.setAttribute("aria-current", "page");
else item.removeAttribute("aria-current");
});
const title = $("#currentViewName");
if (title) {
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;
}
// 原型占位,非本司真待办/真断档数据: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 = `<div class="drawer-head"><div><span class="pill" id="detailTag"></span><h2 class="d-title" id="detailTitle"></h2><p class="d-desc" id="detailDesc"></p></div><button type="button" class="icon-button" data-close-detail aria-label="关闭详情" title="关闭详情"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div><div class="drawer-body"><dl class="kv" id="detailFields"></dl></div><div class="drawer-tip" id="detailTip"></div><div class="drawer-foot"><button type="button" class="btn" data-close-detail>关闭</button><button type="button" class="btn btn-primary" id="detailAction"></button></div>`;
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 = '<svg><use href="icons.svg#menu"></use></svg>';
bar.prepend(button);
});
$$(".menu-button").forEach((menuButton) => {
menuButton.addEventListener("click", () => {
const open = $("#sidebar").classList.toggle("is-open");
menuButton.setAttribute("aria-expanded", String(open));
menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航");
});
});
$$(".side-nav a").forEach((item) => {
const label = $("span", item)?.textContent.trim();
if (label) {
item.setAttribute("aria-label", label);
item.title = label;
}
});
$$("[data-view]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.view); }));
$$("[data-view-link]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.viewLink); }));
$$(".logout").forEach((control) => control.addEventListener("click", async (event) => {
event.preventDefault();
try {
await fetch("/api/logout", { method: "POST" });
} catch { /* 网络异常时仍然回到登录页 */ }
window.location.href = "index.html";
}));
$$("[data-metric-link]").forEach((card) => {
const activate = () => showView(card.dataset.metricLink);
card.addEventListener("click", activate);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
});
$$("[data-metric-action=\"upload\"]").forEach((card) => {
const activate = () => $("[data-open-upload]")?.click();
card.addEventListener("click", activate);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
});
// Delegated: company table rows are rendered from the API after init.
document.addEventListener("click", (event) => {
const toastButton = event.target.closest("[data-toast]");
if (toastButton) showToast(toastButton.dataset.toast);
});
$(".side-nav a[data-view].active")?.setAttribute("aria-current", "page");
document.addEventListener("click", (event) => {
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true });
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeNavigation({ restoreFocus: true });
});
initDetailDrawer();
initMotion();
}
function pairData(from, to, endDate) {
const companies = ["A公司", "B公司", "C公司", "D公司", "E公司", "F公司"];
const fromIndex = companies.indexOf(from) + 1;
const toIndex = companies.indexOf(to) + 1;
const low = Math.min(fromIndex, toIndex);
const high = Math.max(fromIndex, toIndex);
const seed = low * 13 + high * 7;
const canonical = {
receivable: 280 + seed * 18,
otherReceivable: 120 + seed * 9,
payable: 160 + ((seed * 11) % 720),
otherPayable: 80 + ((seed * 5) % 360),
};
const reversed = fromIndex > toIndex;
const receivable = reversed ? canonical.payable : canonical.receivable;
const otherReceivable = reversed ? canonical.otherPayable : canonical.otherReceivable;
const payable = reversed ? canonical.receivable : canonical.payable;
const otherPayable = reversed ? canonical.otherReceivable : canonical.otherPayable;
const debit = receivable + otherReceivable;
const credit = payable + otherPayable;
const opening = ((fromIndex + toIndex) % 3) * 60 * (reversed ? -1 : 1);
const final = opening + debit - credit;
const end = new Date(`${endDate}T12:00:00`);
const dateBefore = (days) => {
const date = new Date(end);
date.setDate(date.getDate() - days);
return date.toISOString().slice(0, 10);
};
return {
opening, debit, credit, final,
totals: { 应收: receivable, 其他应收: otherReceivable, 应付: payable, 其他应付: otherPayable },
rows: [
[dateBefore(2), "转出", "应收", "中信 · 5316", `${to} · 9481`, "往来款", "双边匹配", receivable],
[dateBefore(9), "转出", "其他应收", "建行 · 0845", `${to} · 2046`, "资金调拨", "双边匹配", otherReceivable],
[dateBefore(17), "转入", "应付", "中信 · 5316", `${to} · 9481`, "归还往来款", "双边匹配", payable],
[dateBefore(25), "转入", "其他应付", "农行 · 3650", `${to} · 6120`, "临时往来", "单边待核", otherPayable],
],
};
}
const pairSubjectOrder = ["应收", "其他应收", "应付", "其他应付"];
function setPair(from, to, endDate = "2026-07-31") {
$$('[data-pair-from]').forEach((item) => { item.textContent = from; });
$$('[data-pair-to]').forEach((item) => { item.textContent = to; });
$$("[data-pair-form]").forEach((form) => {
const fromSelect = $('[name="from"]', form);
const toSelect = $('[name="to"]', form);
if (fromSelect && [...fromSelect.options].some((option) => option.value === from)) fromSelect.value = from;
if (toSelect && [...toSelect.options].some((option) => option.value === to)) toSelect.value = to;
const endInput = $('[name="end"]', form);
if (endInput) endInput.value = endDate;
});
if (!$("#pairReport")) return;
const data = pairData(from, to, endDate);
const format = (value) => value.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
$("#pairPeriod").textContent = `统计口径 2026.01.01—${endDate}`;
$("#pairOpening").innerHTML = `${format(data.opening)}<span class="unit">万元</span>`;
$("#pairDebit").innerHTML = `${format(data.debit)}<span class="unit">万元</span>`;
$("#pairCredit").innerHTML = `${format(data.credit)}<span class="unit">万元</span>`;
$("#pairFinal").innerHTML = `${data.final >= 0 ? "应收" : "应付"} ${format(Math.abs(data.final))}<span class="unit">万元</span>`;
$("#pairReviewStatus").textContent = "含 1 笔待审核";
$('[data-subject-total="all"]').textContent = `${data.rows.length} 笔`;
Object.entries(data.totals).forEach(([subject, value]) => { $(`[data-subject-total="${subject}"]`)?.replaceChildren(document.createTextNode(format(value))); });
state.pairContext = { from, to, endDate };
state.pairRows = data.rows.map(([date, direction, subject, ownAccount, counterparty, summary, match, amount]) => ({ date, direction, subject, ownAccount, counterparty, summary, match, amount }));
renderPairRows();
}
function renderPairRows() {
const tbody = $("#pairTransactions");
if (!tbody) return;
const format = (value) => Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
tbody.innerHTML = state.pairRows.map((row, index) => {
const matched = row.match === "双边匹配";
return `<tr data-subject="${row.subject}" data-pair-idx="${index}">
<td class="num">${row.date}</td>
<td>${row.direction}</td>
<td>${row.subject}</td>
<td>${row.ownAccount}</td>
<td>${row.counterparty}</td>
<td class="wrap">${row.summary}</td>
<td><span class="pill ${matched ? "pill-success" : "pill-warn"}">${row.match}</span></td>
<td class="num-col ${row.direction === "转出" ? "amt-out" : "amt-in"}">${format(row.amount)}</td>
<td><button type="button" class="btn btn-sm" data-trace="${index}">穿透</button></td>
</tr>`;
}).join("");
}
function openTrace(index) {
const modal = $("#traceModal");
if (!modal) return;
const row = state.pairRows?.[index];
if (!row) return;
const ctx = state.pairContext || { from: "—", to: "—" };
$("#traceSub").textContent = `${ctx.from}${ctx.to} · ${row.subject} · ${row.date} · ${row.direction} ${formatCurrency(row.amount)} 万元`;
$("#kvOwnTx").textContent = "—(演示数据,未关联银行流水)";
$("#kvOwnAcct").textContent = row.ownAccount;
$("#kvPeerCo").textContent = ctx.to;
$("#kvPeerAcct").textContent = row.counterparty;
$("#kvTime").textContent = row.date;
$("#kvAmt").textContent = `${formatCurrency(row.amount)} 万元`;
$("#kvBatch").textContent = "—";
const evidence = $("#kvEvidence");
if (row.match === "双边匹配") { evidence.textContent = "—"; evidence.style.color = ""; }
else { evidence.textContent = "待对方提供"; evidence.style.color = "var(--warn)"; }
modal.classList.add("open");
}
function initPairQueries() {
$$("[data-pair-form]").forEach((form) => {
$("[data-swap]", form)?.addEventListener("click", () => {
const from = $('[name="from"]', form);
const to = $('[name="to"]', form);
const previous = from.value;
from.value = to.value;
to.value = previous;
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const from = $('[name="from"]', form).value;
const to = $('[name="to"]', form).value;
const endDate = $('[name="end"]', form)?.value || "2026-07-31";
const error = $("#pairError");
if (from === to) {
if (error) { error.style.display = ""; }
showToast("请选择两个不同的公司", "同公司账户调拨不进入公司间往来查询", "warn");
return;
}
if (error) error.style.display = "none";
const notice = $("#pairNotice");
if (notice) notice.style.display = "none";
setPair(from, to, endDate);
showView("pair");
});
});
$$("[data-pair-link]").forEach((button) => button.addEventListener("click", () => {
const [from, to] = button.dataset.pairLink.split("|");
setPair(from, to);
showView("pair");
}));
$$("[data-subject-filter]").forEach((button) => button.addEventListener("click", () => {
const subject = button.dataset.subjectFilter;
$$("[data-subject-filter]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
item.setAttribute("aria-pressed", String(active));
});
$$("#pairTransactions tr").forEach((row) => { row.hidden = subject !== "all" && row.dataset.subject !== subject; });
}));
$("#pairTransactions")?.addEventListener("click", (event) => {
const button = event.target.closest("[data-trace]");
if (button) openTrace(Number(button.dataset.trace));
});
$("#traceClose")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open"));
$("#traceOk")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open"));
$("#traceModal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); });
}
function manualStatusMeta(status) {
if (status === "已确认") return { cls: "pill-success", label: "已通过" };
if (status === "已驳回") return { cls: "pill-danger", label: "已驳回" };
return { cls: "pill-info", label: "待审核" };
}
function renderCompanyManualRecords() {
const tbody = $("#manualRecordRows");
if (!tbody) return;
tbody.replaceChildren();
const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司");
[...records].reverse().forEach((record) => {
const row = document.createElement("tr");
row.dataset.storedRecord = record.id;
const date = document.createElement("td"); date.className = "num"; date.textContent = record.transactionDate || "—";
const direction = document.createElement("td"); direction.textContent = record.direction || "—";
const counterparty = document.createElement("td");
const counterpartyName = document.createElement("span"); counterpartyName.className = "cell-main"; counterpartyName.textContent = record.counterparty;
const counterpartyType = document.createElement("span"); counterpartyType.className = "cell-sub"; counterpartyType.textContent = record.counterpartyType || "";
counterparty.append(counterpartyName, counterpartyType);
const subject = document.createElement("td"); subject.innerHTML = `<span class="tag">${record.subject || "—"}</span>`;
const isIn = record.direction === "收款";
const amount = document.createElement("td");
amount.className = `num-col ${isIn ? "amt-in" : "amt-out"}`;
amount.textContent = `${isIn ? "+" : "-"}¥ ${formatCurrency(record.amount)}`;
const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = record.summary || "—";
const statusMeta = manualStatusMeta(record.status);
const statusCell = document.createElement("td");
statusCell.innerHTML = `<span class="pill ${statusMeta.cls}">${statusMeta.label}</span>`;
const action = document.createElement("td");
if (record.status === "待管理复核") {
action.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-action="withdraw">撤回</button>';
} else {
action.innerHTML = '<span class="muted">—</span>';
}
row.append(date, direction, counterparty, subject, amount, summary, statusCell, action);
tbody.append(row);
});
updateManualCounts();
}
function updateManualCounts() {
const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司");
const pending = records.filter((record) => record.status === "待管理复核").length;
const pendingEl = $("#manualPendingStatus");
if (pendingEl) pendingEl.textContent = pending;
const foot = $("#manualFoot");
if (foot) foot.textContent = `共 ${records.length} 条 · 待复核 ${pending} 条`;
const empty = $("#manualEmpty");
if (empty) empty.hidden = records.length > 0;
}
function fillAccountSelects(accounts) {
const usable = accounts.filter((account) => account.usable);
[$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => {
if (!select) return;
const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账");
select.replaceChildren(...kept);
usable.forEach((account) => {
const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`;
const option = new Option(value, value);
option.dataset.accountId = account.id;
select.add(option);
});
});
}
function companyAccountMeta(status) {
if (status === "active") return { status: { cls: "pill-success", label: "启用" }, audit: { cls: "pill-success", label: "已审核" } };
if (status === "returned") return { status: { cls: "pill-danger", label: "已退回" }, audit: { cls: "pill-danger", label: "已退回" } };
if (status === "disabled") return { status: { cls: "pill-muted", label: "停用" }, audit: { cls: "pill-success", label: "已审核" } };
return { status: { cls: "pill-info", label: "待启用" }, audit: { cls: "pill-info", label: "待审核" } };
}
function renderCompanyAccounts(accounts) {
state.accounts = accounts;
const tbody = $("#account-tbody");
if (tbody) {
tbody.replaceChildren(...accounts.map((account) => {
const row = document.createElement("tr");
row.dataset.accountId = account.id;
const identity = document.createElement("td");
const name = document.createElement("span"); name.className = "cell-main"; name.textContent = account.bank_name;
const tail = document.createElement("span"); tail.className = "cell-sub"; tail.textContent = `尾号 ${accountTail(account.account_number_masked)}`;
identity.append(name, tail);
const type = document.createElement("td"); type.innerHTML = `<span class="tag">${account.account_type || "—"}</span>`;
const meta = companyAccountMeta(account.status);
const statusCell = document.createElement("td"); statusCell.innerHTML = `<span class="pill ${meta.status.cls}">${meta.status.label}</span>`;
const coverage = document.createElement("td");
coverage.className = "num muted";
coverage.textContent = account.usable ? "尚未上传" : "—";
const auditCell = document.createElement("td"); auditCell.innerHTML = `<span class="pill ${meta.audit.cls}">${meta.audit.label}</span>`;
const action = document.createElement("td"); action.innerHTML = '<button type="button" class="btn btn-sm" data-account-view>查看明细</button>';
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 = '<span class="pill pill-warn">中</span>';
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 = `<span class="pill ${pillClass(status.className)}">${status.label}</span>`;
const actionCell = document.createElement("td");
if (row.dataset.resolved) {
const isActiveAccount = isAccount && record.status === "active";
if (isActiveAccount) {
actionCell.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-audit-action="disable">停用</button>';
} else {
const resolvedLabel = isAccount
? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过"
: (statusLabel === "已确认" || record.state === "approved" ? "已通过" : "已驳回");
actionCell.innerHTML = `<span class="meta">${resolvedLabel} · 系统管理员</span>`;
}
} else {
actionCell.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div>';
}
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 = '<span class="pill pill-danger">高</span>';
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 = '<span class="pill pill-danger">待处理</span>';
const actionCell = document.createElement("td");
actionCell.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">关闭异常</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">退回重匹配</button></div>';
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}<span class="unit">项</span>`;
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 = `<span class="pill ${pillClass(badge.className)}">${badge.label}</span>`;
const actionCell = document.createElement("td");
actionCell.innerHTML = `<button type="button" class="btn btn-sm" data-company-view="${company.id}">查看</button>`;
row.append(nameCell, accounts, usernames, cashier, statusCell, actionCell);
return row;
}));
const foot = $("#companyFoot");
if (foot) foot.textContent = `共 ${companies.length} 家公司`;
}
function updatePendingAccountNotice() {
const rows = $$('#auditRows tr[data-record-kind="account"]');
const pending = rows.filter((r) => r.dataset.resolved !== "true");
const notice = $("#notice-pending-account");
if (!notice) return;
if (pending.length) {
notice.style.display = "";
$("#pending-account-count").textContent = pending.length;
const names = [...new Set(pending.map((r) => r.dataset.company))].join("、");
$("#pending-account-body").textContent = (names || "成员公司") + " 提交了新银行账户登记,等待审核通过后纳入账期流水归集范围。";
} else {
notice.style.display = "none";
}
}
function setSelectOptions(select, names, { keepFirst = false } = {}) {
if (!select || !names.length) return;
const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : [];
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
}
function fillCompanySelects(names) {
// Every company picker is driven by master data: a newly created company
// appears in pair queries, audit filters, flow filters and reminders
// without any code change.
$$("[data-pair-form]").forEach((form) => {
setSelectOptions($('[name="from"]', form), names);
setSelectOptions($('[name="to"]', form), names);
const toSelect = $('[name="to"]', form);
if (toSelect && names.length > 1) toSelect.value = names[1];
});
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
const reminderSelect = $("#reminder-company");
if (reminderSelect) {
const kept = reminderSelect.querySelector('option[value=""]') ? [reminderSelect.options[0].cloneNode(true)] : [];
const companies = state.companies || [];
reminderSelect.replaceChildren(...kept, ...companies.map((company) => new Option(company.name, company.id)));
}
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 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 = '<div class="dash-company-empty muted">暂无公司或尚无归集往来</div>';
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 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m6 9 6 6 6-6"/></svg>';
}
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m9 18 6-6-6-6"/></svg>';
}
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 = '<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>';
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 = `
<td style="font-weight:550;">${chevronSvg(expanded)}<span data-peer-label>${group.peer_name}</span></td>
<td class="num-col">${group.count}</td>
<td class="num-col meta">—</td>
<td class="num-col">${Number(group.debit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td class="num-col">${Number(group.credit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td class="num-col ${netClass(group.ending_wan)}">${formatWan(group.ending_wan)}</td>`;
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"
? '<span class="tag">借</span>'
: '<span class="tag">贷</span>';
lineTr.innerHTML = `
<td style="padding-left: 28px;">${dirTag} <span class="meta">${line.date}</span> ${line.summary}</td>
<td class="num-col">1</td>
<td class="num-col meta">—</td>
<td class="num-col">${line.direction === "debit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
<td class="num-col">${line.direction === "credit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
<td class="num-col meta">—</td>`;
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 = '<tr class="loading-row"><td colspan="6">加载中…</td></tr>';
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 = '<tr><td colspan="6" class="muted">明细加载失败</td></tr>';
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 = '<div class="dash-company-empty muted">总览加载失败</div>';
return;
}
const data = await response.json().catch(() => null);
if (!data || data.status !== "ok") {
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
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)}<span data-peer-label>${tr.dataset.peerName || ""}</span>`;
}
$$('#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();
}
async function loadAdminCompanies() {
const tbody = $("#companyTable tbody");
showTableLoading(tbody, 6);
const response = await fetch("/api/admin/companies").catch(() => null);
if (!response?.ok) {
showTableError(tbody, 6);
return;
}
const result = await response.json().catch(() => null);
const companies = result?.companies || [];
renderAdminCompanyTable(companies);
fillCompanySelects(companies.map((company) => company.name));
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
}
function initAdmin() {
renderStoredAdminReviews();
updateAuditCounts();
loadAdminCompanies();
initDashboard();
let activeAuditType = "all";
function filterAuditRows() {
const company = $("#auditCompany")?.value || "全部公司";
$$(".audit-table tbody tr").forEach((row) => {
const typeMatches = activeAuditType === "all" || row.dataset.auditType === activeAuditType;
const companyMatches = company === "全部公司" || row.dataset.company === company;
row.hidden = !(typeMatches && companyMatches);
});
}
$$("[data-audit-filter]").forEach((button) => button.addEventListener("click", () => {
activeAuditType = button.dataset.auditFilter;
$$("[data-audit-filter]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
item.setAttribute("aria-pressed", String(active));
});
filterAuditRows();
}));
$("#auditCompany")?.addEventListener("change", filterAuditRows);
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
async function submitAuditResult(row) {
const decision = state.auditDecision;
const reason = state.auditReason || "";
const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用") || 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) {
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) {
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", (event) => {
const button = event.target.closest("[data-audit-action]");
if (!button) return;
const row = button.closest("tr");
state.auditRow = row;
const cells = $$("td", row);
const company = cells[1]?.querySelector(".cell-main")?.textContent.trim() || "";
const period = cells[3]?.textContent.trim() || "";
const type = cells[2]?.textContent.trim() || "";
const basis = row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明";
const action = button.dataset.auditAction;
if (action === "approve") {
$("#approve-company").textContent = company;
$("#approve-period").textContent = period;
$("#approve-type").textContent = type;
$("#approve-basis").textContent = basis;
openModal("modal-approve");
} else if (action === "reject") {
$("#reject-reason").value = "";
$("#reject-hint").style.display = "none";
openModal("modal-reject");
} else if (action === "disable") {
state.auditDecision = "停用并驳回";
state.auditReason = "停用并驳回该账户";
submitAuditResult(row);
}
});
$("#approve-confirm")?.addEventListener("click", () => {
if (!state.auditRow) return;
closeModal("modal-approve");
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 data = new FormData(event.currentTarget);
const companyName = String(data.get("companyName") || "").trim();
const loginName = String(data.get("loginName") || "").trim();
const createUser = data.get("createUser") !== null;
const payload = {
name: companyName,
credit_code: String(data.get("creditCode") || "").trim(),
cashier_name: String(data.get("cashier") || "").trim(),
};
if (createUser && loginName) payload.username = loginName;
const response = await fetch("/api/admin/companies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("公司创建失败", result?.message || "请稍后重试", "danger");
return;
}
const accountCreated = Boolean(result.username);
closeModal("companyDialog");
event.currentTarget.reset();
await loadAdminCompanies();
showToast(
accountCreated ? "公司与账号已创建" : "公司已创建",
accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
"success",
);
});
function openCompanyDetail(companyId) {
const company = (state.companies || []).find((c) => String(c.id) === String(companyId));
if (!company) return;
$("#d-title").textContent = company.name;
$("#d-sub").textContent = `统一社会信用代码 ${company.credit_code || "待补充"}`;
$("#d-kv").innerHTML =
'<dt>公司名称</dt><dd>' + company.name + '</dd>' +
'<dt>统一社会信用代码</dt><dd>' + (company.credit_code || "待补充") + '</dd>' +
'<dt>出纳</dt><dd>' + (company.cashier_name || "未指定") + '</dd>' +
'<dt>银行账户</dt><dd>' + (company.account_count ?? 0) + ' 个</dd>' +
'<dt>状态</dt><dd>' + companyStatusBadge(company.status).label + '</dd>';
$("#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 payload = {
closing_day: String(day),
start_date: $("#cs-start")?.value || "",
auto_remind: $("#cs-remind")?.checked ? "1" : "0",
remind_days: $("#cs-remind-days")?.value || "3",
};
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 || {});
showToast("系统计算口径已保存", "修改前后值与操作人已留痕", "success");
});
$("#runClosingCheck")?.addEventListener("click", () => {
const unresolved = $$(".audit-table tbody tr").filter((row) => row.dataset.resolved !== "true").length;
const blockNotice = $("#block-notice");
if (unresolved) {
if (blockNotice) blockNotice.style.display = "";
$("#closingDescription").textContent = `2026 年 7 月 · 仍有 ${unresolved} 项审核事项未处理`;
$("#closingStatus").className = "pill pill-danger";
$("#closingStatus").textContent = "已阻断";
$("#executeClosing").disabled = true;
showToast("结账检查未通过", `仍有 ${unresolved} 项审核事项,已打开审核中心`, "warn");
showView("audit");
} else {
if (blockNotice) blockNotice.style.display = "none";
$$("#closingPanel [data-closing-state]").forEach((item) => {
item.className = "pill pill-success";
item.textContent = "已通过";
});
$("#closingDescription").textContent = "2026 年 7 月 · 全部前置检查已通过";
$("#closingStatus").className = "pill pill-success";
$("#closingStatus").textContent = "可结账";
$("#executeClosing").disabled = false;
$("#executeClosing").removeAttribute("title");
showToast("结账检查通过", "执行结账按钮已解锁", "success");
}
});
$("#executeClosing")?.addEventListener("click", () => openModal("closingDialog"));
$$("[data-close-closing]").forEach((button) => button.addEventListener("click", () => closeModal("closingDialog")));
$("#closingForm")?.addEventListener("submit", (event) => {
event.preventDefault();
closeModal("closingDialog");
$("#closingDescription").textContent = "2026 年 7 月 · 已完成集团结账";
$("#closingStatus").className = "pill pill-success";
$("#closingStatus").textContent = "已结账";
$("#closingHistory").textContent = `${new Date().toLocaleString("zh-CN", { hour12: false })} · 系统管理员执行 2026 年 7 月结账 · 已写入审计记录`;
$("#runClosingCheck").disabled = true;
$("#executeClosing").disabled = true;
$("#executeClosing").textContent = "7 月已结账";
const closedNotice = $("#closed-notice");
if (closedNotice) closedNotice.style.display = "";
const tlCurrent = $("#tl-current");
tlCurrent?.classList.remove("current");
tlCurrent?.classList.add("closed");
$("#tl-current-state").textContent = "已结账";
showToast("2026 年 7 月已完成结账", "本期结果已锁定,后续补录将进入重开流程", "success");
});
$("#openOpeningDialog")?.addEventListener("click", () => openModal("openingDialog"));
$$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog")));
$("#openingForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
if (data.get("from") === data.get("to")) {
showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初", "warn");
return;
}
const row = document.createElement("tr");
const self = document.createElement("td"); self.className = "cell-main"; self.textContent = data.get("from"); row.append(self);
const peer = document.createElement("td"); peer.textContent = data.get("to"); row.append(peer);
const subject = document.createElement("td"); subject.textContent = data.get("subject"); row.append(subject);
const direction = document.createElement("td"); direction.textContent = data.get("direction"); row.append(direction);
const amount = document.createElement("td"); amount.className = "num-col"; amount.textContent = Number(data.get("amount")).toLocaleString("zh-CN", { minimumFractionDigits: 2 }); row.append(amount);
const date = document.createElement("td"); date.className = "num"; date.textContent = data.get("effectiveDate"); row.append(date);
const status = document.createElement("td"); status.innerHTML = '<span class="pill pill-warn">待复核</span>'; row.append(status);
$("#openingRows").append(row);
closeModal("openingDialog");
event.currentTarget.reset();
showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人", "success");
});
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
const reminderSelect = $("#reminder-company");
const reminderItems = $("#reminder-items");
const reminderError = $("#reminder-error");
const companyError = $("#company-error");
const sendBtn = $("#send-btn");
const esc = (text) => String(text ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const fmtReminderTime = (iso) => String(iso || "").replace("T", " ").slice(5, 16);
let pendingReminderItems = [];
function renderPendingItems(items) {
pendingReminderItems = items || [];
if (!reminderItems) return;
if (!pendingReminderItems.length) {
reminderItems.innerHTML = '<div class="empty">该公司当前没有待提醒事项。</div>';
if (sendBtn) sendBtn.disabled = true;
return;
}
reminderItems.replaceChildren(...pendingReminderItems.map((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML =
'<span class="pill pill-warn" style="flex:none;">待处理</span>' +
'<div class="lr-main"><div class="lr-title">' + esc(item.kind) + '</div>' +
'<div class="lr-sub">' + esc(item.content) + '</div></div>';
return row;
}));
if (sendBtn) sendBtn.disabled = false;
}
async function loadPendingReminders(companyId) {
if (!companyId) { renderPendingItems([]); return; }
if (reminderItems) reminderItems.innerHTML = '<div class="loading-row">正在列出待提醒事项…</div>';
if (sendBtn) sendBtn.disabled = true;
const response = await fetch("/api/admin/reminders/pending?company_id=" + encodeURIComponent(companyId)).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) { renderPendingItems([]); return; }
renderPendingItems(result.items || []);
}
reminderSelect?.addEventListener("change", () => {
if (reminderError) reminderError.style.display = "none";
loadPendingReminders(reminderSelect.value);
});
$("#reminderForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const companyId = reminderSelect?.value;
if (companyError) companyError.style.display = companyId ? "none" : "";
if (!companyId) return;
if (!pendingReminderItems.length) {
if (reminderError) reminderError.style.display = "";
return;
}
if (reminderError) reminderError.style.display = "none";
if (sendBtn) sendBtn.disabled = true;
const response = await fetch("/api/admin/reminders/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ company_id: Number(companyId) }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
if (sendBtn) sendBtn.disabled = false;
showToast("发送失败", result?.message || "请稍后重试", "danger");
return;
}
const sendHint = $("#send-hint");
if (sendHint) {
sendHint.style.display = "";
sendHint.textContent = `已向 ${result.company_name} 发送 ${(result.reminders || []).length} 项提醒,截止 ${result.deadline || "—"}。`;
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
}
await loadReminderHistory();
await loadPendingReminders(companyId);
showToast("提醒已发送", `已生成 ${(result.reminders || []).length} 条提醒记录`, "success");
});
// ── 提醒历史(实时从后端读取) ──
let reminderFilter = "all";
function refreshReminderCounts() {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
const countAll = $("#count-all"), countSys = $("#count-system"), countMan = $("#count-manual");
if (countAll) countAll.textContent = all;
if (countSys) countSys.textContent = sys;
if (countMan) countMan.textContent = man;
const foot = $("#table-foot-count");
if (foot) foot.textContent = "共 " + all + " 条提醒记录";
const state = $("#table-foot-state");
if (state) state.textContent = "未读 " + all + " · 处理中 0 · 已完成 0";
}
function applyReminderFilter() {
$$("#reminder-tbody tr").forEach((r) => {
r.style.display = (reminderFilter === "all" || r.dataset.source === reminderFilter) ? "" : "none";
});
}
async function loadReminderHistory() {
const tbody = $("#reminder-tbody");
if (!tbody) return;
const response = await fetch("/api/admin/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
const reminders = result?.reminders || [];
tbody.replaceChildren(...reminders.map((reminder) => {
const row = document.createElement("tr");
row.dataset.source = reminder.source;
row.innerHTML =
'<td class="cell-main">' + esc(reminder.company_name) + '</td>' +
'<td><span class="tag">' + esc(reminder.kind) + '</span></td>' +
'<td class="wrap">' + esc(reminder.content) + '</td>' +
'<td class="meta">' + fmtReminderTime(reminder.created_at) + '<span class="cell-sub">' + (reminder.source === "manual" ? "人工" : "系统") + (reminder.actor_username ? " · " + esc(reminder.actor_username) : "") + '</span></td>' +
'<td class="meta">' + (reminder.deadline || "—") + '</td>' +
'<td><span class="pill pill-danger">未读</span></td>' +
'<td><button type="button" class="btn btn-sm btn-ghost act-history" data-company-id="' + reminder.company_id + '" data-company="' + esc(reminder.company_name) + '">查看历史</button></td>';
return row;
}));
applyReminderFilter();
refreshReminderCounts();
}
$("#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");
reminderFilter = btn.dataset.filter;
applyReminderFilter();
});
$("#reminder-tbody")?.addEventListener("click", (event) => {
const historyBtn = event.target.closest(".act-history");
if (historyBtn) openReminderHistory(historyBtn.dataset.companyId, historyBtn.dataset.company);
});
async function openReminderHistory(companyId, companyName) {
$("#history-title").textContent = (companyName || "") + " · 提醒历史";
$("#history-sub").textContent = "该公司的全部提醒触达记录,按时间倒序。";
const list = $("#history-list");
list.innerHTML = '<div class="loading-row">加载中…</div>';
const response = await fetch("/api/admin/reminders?company_id=" + encodeURIComponent(companyId || "")).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
const items = result?.reminders || [];
if (!items.length) {
list.innerHTML = '<div class="empty"><div class="e-title">暂无历史记录</div>该公司尚未收到过提醒。</div>';
} else {
list.innerHTML = items.map((it) => {
return '<div class="list-row">' +
'<span class="tag">' + esc(it.kind) + '</span>' +
'<div class="lr-main"><div class="lr-title">' + esc(it.content) + '</div><div class="lr-sub"><span class="meta">' + fmtReminderTime(it.created_at) + ' · ' + (it.source === "manual" ? "人工" : "系统") + (it.actor_username ? " · " + esc(it.actor_username) : "") + '</span></div></div>' +
'<div class="lr-side"><span class="pill pill-danger">未读</span></div></div>';
}).join("");
}
openModal("history-modal");
}
$("#history-close")?.addEventListener("click", () => closeModal("history-modal"));
$("#history-ok")?.addEventListener("click", () => closeModal("history-modal"));
loadSystemSettings();
loadReminderHistory();
}
const FLOW_DEMO = [
{ date: "2026-07-01", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "平顶山市恒源电力燃料有限公司", summary: "煤炭销售款(6 月结算)", serial: "ICBC202607010031825", status: "未归集", statusPill: "pill-muted", amount: "1,860,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-01 09:42:17", peer: "平顶山市恒源电力燃料有限公司", peerAcct: "工行平顶山分行 1702 0218 0902 6641 20", amount: "¥ 1,860,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对方为集团外客户,不进入内部往来归集,仅作银行流水留档。" } },
{ date: "2026-07-03", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "光伏支架材料款", serial: "ICBC202607030094417", status: "单边", statusPill: "pill-danger", amount: "1,620,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-03 14:08:52", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,620,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-002", note: "贸易侧 7 月上报流水中未找到对应收款,已挂起为单边流水,待贸易侧补充银行凭证佐证。" } },
{ date: "2026-07-03", company: "金牛置业", bank: "中行", account: "8821", acctLabel: "中行 · 尾号 8821", dir: "收", dirPill: "pill-success", peer: "郑州市商品房预售资金监管专户", summary: "商品房预售款(A 区 12 号楼)", serial: "BOC202607030552108", status: "未归集", statusPill: "pill-muted", amount: "4,150,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛置业有限公司 · 一般户 2546 0387 0200 8821", time: "2026-07-03 10:26:31", peer: "郑州市商品房预售资金监管专户", peerAcct: "中行郑州郑东新区支行 2546 1180 0200 3477", amount: "¥ 4,150,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "预售监管资金划入,属对外经营收款,不参与集团内部往来归集。" } },
{ date: "2026-07-05", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "河南金牛物流有限公司", summary: "矿区运输费(6 月)", serial: "ICBC202607050127663", status: "已归集", statusPill: "pill-success", amount: "1,240,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-05 11:15:09", peer: "河南金牛物流有限公司", peerAcct: "建行郑州经开区支行 4105 0167 8080 5562", amount: "¥ 1,240,000.00(付)", status: "已归集", pair: "金牛煤业 ↔ 金牛物流", subject: "应付(煤业侧)", batch: "JC-2026-07-014", note: "与物流侧建行尾号 5562 账户 07-05 收款流水双向匹配,金额一致。" } },
{ date: "2026-07-05", company: "金牛物流", bank: "建行", account: "5562", acctLabel: "建行 · 尾号 5562", dir: "收", dirPill: "pill-success", peer: "河南金牛煤业有限公司", summary: "矿区运输费(6 月)", serial: "CCB202607050312940", status: "已归集", statusPill: "pill-success", amount: "1,240,000.00", amtClass: "amt-in", detail: { bank: "建设银行", account: "河南金牛物流有限公司 · 基本户 4105 0167 8080 5562", time: "2026-07-05 11:15:36", peer: "河南金牛煤业有限公司", peerAcct: "工行平顶山分行 1702 0231 0900 8133 05", amount: "¥ 1,240,000.00(收)", status: "已归集", pair: "金牛物流 ↔ 金牛煤业", subject: "应收(物流侧)", batch: "JC-2026-07-014", note: "与煤业侧工行尾号 3305 账户 07-05 付款流水双向匹配,金额一致。" } },
{ date: "2026-07-08", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "煤炭采购款(7 月)", serial: "ICBC202607080208554", status: "待确认", statusPill: "pill-warn", amount: "3,200,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-08 15:47:22", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 3,200,000.00(收)", status: "待确认", pair: "金牛煤业 ↔ 金牛置业", subject: "应收(煤业侧)", batch: "—(待归集)", note: "置业中行尾号 8821 账户 07-06 至 07-16 流水断档,对方付款凭证缺失,暂无法完成双边匹配,已列入审核中心高风险事项。" } },
{ date: "2026-07-09", company: "金牛贸易", bank: "农行", account: "2208", acctLabel: "农行 · 尾号 2208", dir: "收", dirPill: "pill-success", peer: "洛阳建工集团有限公司", summary: "钢材销售款(6 月发货)", serial: "ABC202607090773261", status: "未归集", statusPill: "pill-muted", amount: "2,480,000.00", amtClass: "amt-in", detail: { bank: "农业银行", account: "河南金牛贸易有限公司 · 基本户 1606 3301 0400 0220 8", time: "2026-07-09 09:58:44", peer: "洛阳建工集团有限公司", peerAcct: "中行洛阳分行 2546 2201 0500 7915", amount: "¥ 2,480,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外钢材销售回款,不参与集团内部往来归集。" } },
{ date: "2026-07-11", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "电缆及配电柜采购款", serial: "ICBC202607110158902", status: "单边", statusPill: "pill-danger", amount: "1,950,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-11 16:32:08", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,950,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-005", note: "贸易侧无对应收款记录,单边挂起。新能源↔贸易本月累计 3 笔单边流水,合计 486 万元。" } },
{ date: "2026-07-14", company: "金牛煤业", bank: "中行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "平顶山天安煤业设备租赁有限公司", summary: "综采设备租赁费(7 月)", serial: "BOC202607140416337", status: "未归集", statusPill: "pill-muted", amount: "920,000.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-14 10:11:57", peer: "平顶山天安煤业设备租赁有限公司", peerAcct: "建行平顶山分行 4105 0229 8080 1347", amount: "¥ 920,000.00(付)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "综采设备月度租赁支出,对方为集团外供应商,不参与内部归集。" } },
{ date: "2026-07-18", company: "金牛煤业", bank: "交行", account: "7710", acctLabel: "交行 · 尾号 7710", dir: "付", dirPill: "pill-danger", peer: "平顶山市安泰矿山设备有限公司", summary: "提升机大修款", serial: "BOCOM202607180062194", status: "待确认", statusPill: "pill-warn", amount: "685,400.00", amtClass: "amt-out", detail: { bank: "交通银行", account: "河南金牛煤业有限公司 · 一般户 4110 6120 0181 0077 10(账户待审核)", time: "2026-07-18 13:29:40", peer: "平顶山市安泰矿山设备有限公司", peerAcct: "工行平顶山分行 1702 0218 0902 9075 63", amount: "¥ 685,400.00(付)", status: "待确认", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "付款账户(交行尾号 7710)为新开户,尚在账户审核流程中,流水暂挂待确认,审核通过后自动归档为外部交易。" } },
{ date: "2026-07-21", company: "金牛置业", bank: "中行", account: "8821", acctLabel: "中行 · 尾号 8821", dir: "收", dirPill: "pill-success", peer: "郑州市商品房预售资金监管专户", summary: "商品房预售款(A 区 15 号楼)", serial: "BOC202607210588420", status: "未归集", statusPill: "pill-muted", amount: "3,780,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛置业有限公司 · 一般户 2546 0387 0200 8821", time: "2026-07-21 09:35:12", peer: "郑州市商品房预售资金监管专户", peerAcct: "中行郑州郑东新区支行 2546 1180 0200 3477", amount: "¥ 3,780,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "预售监管资金划入。该账户 07-06 至 07-16 存在流水断档,本笔为断档后首笔入账。" } },
{ date: "2026-07-22", company: "金牛物流", bank: "建行", account: "5562", acctLabel: "建行 · 尾号 5562", dir: "收", dirPill: "pill-success", peer: "河南金牛贸易有限公司", summary: "钢材干线运输费(6-7 月)", serial: "CCB202607220347815", status: "已归集", statusPill: "pill-success", amount: "462,800.00", amtClass: "amt-in", detail: { bank: "建设银行", account: "河南金牛物流有限公司 · 基本户 4105 0167 8080 5562", time: "2026-07-22 14:52:26", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 462,800.00(收)", status: "已归集", pair: "金牛物流 ↔ 金牛贸易", subject: "应收(物流侧)", batch: "JC-2026-07-021", note: "与贸易侧农行尾号 2208 账户 07-22 付款流水双向匹配,金额一致,已计入 7 月往来批次。" } },
{ date: "2026-07-24", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "组件辅材结算款", serial: "ICBC202607240221476", status: "单边", statusPill: "pill-danger", amount: "1,290,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-24 11:06:33", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,290,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-009", note: "贸易侧无对应收款记录,单边挂起。新能源↔贸易本月累计 3 笔单边流水,合计 486 万元。" } },
{ date: "2026-07-25", company: "金牛贸易", bank: "农行", account: "2208", acctLabel: "农行 · 尾号 2208", dir: "付", dirPill: "pill-danger", peer: "安阳钢铁集团有限责任公司", summary: "螺纹钢采购款(7 月)", serial: "ABC202607250819673", status: "未归集", statusPill: "pill-muted", amount: "5,620,000.00", amtClass: "amt-out", detail: { bank: "农业银行", account: "河南金牛贸易有限公司 · 基本户 1606 3301 0400 0220 8", time: "2026-07-25 10:19:05", peer: "安阳钢铁集团有限责任公司", peerAcct: "工行安阳分行 1706 0211 0900 4428 17", amount: "¥ 5,620,000.00(付)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外螺纹钢采购付款,不参与集团内部往来归集。" } },
];
// 原型占位,非本司真流水:COMPANY_FLOWS 仅用于演示公司端流水列表界面,
// 真实流水来自 /api/parse 导入与 /api/batches 批次,切勿把它当成真数据源。
const COMPANY_FLOWS = [
{ date: "2026-07-02", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "煤炭采购款(2026 年 6 月供煤合同结算)", serial: "ICBC2026070200185347", status: "已归集", statusPill: "pill-success", amount: "3,200,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-02 15:47:22", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 3,200,000.00(收)", status: "已归集", pair: "金牛煤业 ↔ 金牛置业", subject: "应收(煤业侧)", batch: "JH-202607-014", note: "与置业侧中行尾号 8821 账户付款流水已双向匹配。" } },
{ date: "2026-07-03", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "山西晋城王坡煤矿有限责任公司", summary: "原料煤采购预付款", serial: "ICBC2026070300221091", status: "未归集 · 外部", statusPill: "pill-muted", amount: "860,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-03 10:26:31", peer: "山西晋城王坡煤矿有限责任公司", peerAcct: "工行晋城分行 1702 0218 0902 6641 20", amount: "¥ 860,000.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对方为集团外供应商,不参与内部往来归集,仅作银行流水留档。" } },
{ date: "2026-07-06", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "河南神火运销有限公司", summary: "动力煤销售货款(7 月第一批)", serial: "BOC2026070600772018", status: "未归集 · 外部", statusPill: "pill-muted", amount: "1,246,800.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-06 09:58:44", peer: "河南神火运销有限公司", peerAcct: "工行永城分行 1702 0218 0902 9075 63", amount: "¥ 1,246,800.00(收)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外动力煤销售回款,不参与集团内部往来归集。" } },
{ date: "2026-07-08", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "河南金牛物流有限公司", summary: "6 月煤炭公路运输费结算", serial: "ICBC2026070800311276", status: "已归集", statusPill: "pill-success", amount: "486,500.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-08 11:15:09", peer: "河南金牛物流有限公司", peerAcct: "建行郑州经开区支行 4105 0167 8080 5562", amount: "¥ 486,500.00(付)", status: "已归集", pair: "金牛煤业 ↔ 金牛物流", subject: "应付(煤业侧)", batch: "JH-202607-014", note: "与物流侧建行收款流水已双向匹配,运单 42 张随附。" } },
{ date: "2026-07-10", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "选煤设备配件代购款", serial: "BOC2026071000819455", status: "待确认", statusPill: "pill-warn", amount: "214,700.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-10 14:08:52", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 214,700.00(付)", status: "待确认", pair: "金牛煤业 ↔ 金牛贸易", subject: "其他应付(待复核)", batch: "JH-202607-021", note: "贸易侧已确认收款,科目待双方复核(应付 / 其他应付)。" } },
{ date: "2026-07-13", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "国网河南省电力公司新密市供电公司", summary: "7 月工业电费", serial: "ICBC2026071300458820", status: "未归集 · 外部", statusPill: "pill-muted", amount: "1,528,300.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-13 10:19:05", peer: "国网河南省电力公司新密市供电公司", peerAcct: "工行新密支行 1706 0211 0900 4428 17", amount: "¥ 1,528,300.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外电费支出,不参与集团内部往来归集。" } },
{ date: "2026-07-15", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "河南金牛新能源有限公司", summary: "场区租赁费返还(二季度)", serial: "BOC2026071500923314", status: "单边", statusPill: "pill-danger", amount: "95,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-15 11:06:33", peer: "河南金牛新能源有限公司", peerAcct: "工行郑州分行 1704 0512 0900 2266 49", amount: "¥ 95,000.00(收)", status: "单边", pair: "金牛煤业 ↔ 金牛新能源", subject: "其他应收(煤业侧)", batch: "—(待归集)", note: "新能源侧尚未提报对应付款流水,形成单边。" } },
{ date: "2026-07-18", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "国家税务总局新密市税务局", summary: "增值税及附加税费(6 月属期)", serial: "ICBC2026071800506639", status: "未归集 · 外部", statusPill: "pill-muted", amount: "2,073,450.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-18 13:29:40", peer: "国家税务总局新密市税务局", peerAcct: "国库专户", amount: "¥ 2,073,450.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "税费缴库,不参与集团内部往来归集。" } },
{ date: "2026-07-21", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "永城煤电控股集团有限公司", summary: "块煤销售款(年度长协第 7 批)", serial: "BOC2026072101054472", status: "未归集 · 外部", statusPill: "pill-muted", amount: "3,864,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-21 09:35:12", peer: "永城煤电控股集团有限公司", peerAcct: "工行永城分行 1702 0218 0902 3477 12", amount: "¥ 3,864,000.00(收)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外块煤销售回款,不参与集团内部往来归集。" } },
{ date: "2026-07-24", company: "金牛煤业", bank: "交通银行", account: "7710", acctLabel: "交行 · 尾号 7710", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "临时往来款归还", serial: "BCM2026072400088116", status: "待确认", statusPill: "pill-warn", amount: "1,500,000.00", amtClass: "amt-in", detail: { bank: "交通银行", account: "河南金牛煤业有限公司 · 一般户 4110 6120 0181 0077 10(账户待审核)", time: "2026-07-24 14:52:26", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 1,500,000.00(收)", status: "待确认", pair: "金牛煤业 ↔ 金牛置业", subject: "其他应收(煤业侧)", batch: "JH-202607-021", note: "交行 7710 账户尚处待审核,归集结果以账户审核通过后为准。" } },
{ date: "2026-07-27", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "煤业职工工资代发(2026 年 7 月)", summary: "7 月职工工资及奖金代发,共 612 人", serial: "ICBC2026072700582241", status: "未归集 · 外部", statusPill: "pill-muted", amount: "2,416,780.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-27 10:11:57", peer: "煤业职工工资代发(2026 年 7 月)", peerAcct: "代发专户", amount: "¥ 2,416,780.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "工资代发,不参与集团内部往来归集。" } },
{ date: "2026-07-30", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "河南金牛农业科技发展有限公司", summary: "临时周转借款(约定 8 月归还)", serial: "BOC2026073001187903", status: "单边", statusPill: "pill-danger", amount: "800,000.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-30 16:32:08", peer: "河南金牛农业科技发展有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 800,000.00(付)", status: "单边", pair: "金牛煤业 ↔ 金牛农业", subject: "其他应收(煤业侧)", batch: "—(待归集)", note: "农业 7 月未提交流水,暂无对方侧证据。" } },
];
function currentFlowData() {
return portal === "admin" ? FLOW_DEMO : COMPANY_FLOWS;
}
function renderFlowRows() {
const tbody = $("#flowTable tbody");
if (!tbody) return;
const data = currentFlowData();
tbody.innerHTML = data.map((row, index) => {
const companyCell = portal === "admin" ? `<td class="cell-main">${row.company}</td>` : "";
return `<tr class="clickable" data-flow-idx="${index}" data-company="${row.company}" data-bank="${row.bank}" data-account="${row.account}" data-date="${row.date}" data-dir="${row.dir}">
<td class="num">${row.date}</td>
${companyCell}
<td>${row.acctLabel}</td>
<td><span class="pill ${row.dirPill}">${row.dir}</span></td>
<td class="wrap">${row.peer}</td>
<td class="wrap">${row.summary}</td>
<td class="num">${row.serial}</td>
<td><span class="pill ${row.statusPill}">${row.status}</span></td>
<td class="num-col ${row.amtClass}">¥ ${row.amount}</td>
</tr>`;
}).join("");
const count = $("#flowCount");
if (count) count.textContent = `共 ${data.length} 笔`;
const empty = $("#flowEmpty");
if (empty) empty.hidden = data.length > 0;
const sum = $("#flowSum");
if (sum) {
let inflow = 0, outflow = 0;
data.forEach((row) => { if (row.dir === "收") inflow += Number(row.amount.replace(/,/g, "")); else outflow += Number(row.amount.replace(/,/g, "")); });
sum.innerHTML = data.length ? `收 <span class="amt-in">+¥ ${formatCurrency(inflow)}</span> · 付 <span class="amt-out">-¥ ${formatCurrency(outflow)}</span>` : "";
}
}
function openFlowDetail(index) {
const modal = $("#tx-modal");
if (!modal) return;
const row = currentFlowData()[index];
if (!row) return;
$("#tx-modal-sub").textContent = `${row.company} · ${row.acctLabel} · ${row.date}`;
const d = row.detail;
$("#d-serial").textContent = row.serial;
$("#d-bank").textContent = d.bank;
$("#d-account").textContent = d.account;
$("#d-time").textContent = d.time;
$("#d-peer").textContent = d.peer;
$("#d-peer-acct").textContent = d.peerAcct;
$("#d-amount").textContent = d.amount;
$("#d-status").textContent = d.status;
$("#d-pair").textContent = d.pair;
$("#d-subject").textContent = d.subject;
$("#d-batch").textContent = d.batch;
$("#d-note").textContent = d.note;
modal.classList.add("open");
}
function visibleRows(table) {
return $$('tbody tr', table).filter((row) => !row.hidden);
}
function filterFlows() {
const table = $("#flowTable");
if (!table) return;
const company = $("#flowCompany")?.value || "全部公司";
const bank = $("#flowBank")?.value || "全部银行";
const account = $("#flowAccount")?.value || "全部账户";
const startDate = $("#flowStart")?.value || "0000-01-01";
const endDate = $("#flowEnd")?.value || "9999-12-31";
const keyword = $("#flowKeyword")?.value.trim().toLowerCase() || "";
if (startDate > endDate) {
showToast("日期范围无效", "开始日期不能晚于结束日期", "warn");
return;
}
let count = 0;
$$("tbody tr", table).forEach((row) => {
const rowDate = row.dataset.date || $("td", row).textContent.trim().replaceAll(".", "-");
const matchesCompany = company === "全部公司" || row.dataset.company === company;
const matchesBank = bank === "全部银行" || row.dataset.bank === bank;
const matchesAccount = account === "全部账户" || row.dataset.account === account || (row.dataset.account === undefined && row.textContent.includes(account));
const matchesDate = rowDate >= startDate && rowDate <= endDate;
const matchesKeyword = !keyword || row.textContent.toLowerCase().includes(keyword);
row.hidden = !(matchesCompany && matchesBank && matchesAccount && matchesDate && matchesKeyword);
if (!row.hidden) count += 1;
});
$("#flowCount").textContent = count === 0 ? "共 0 条 · 无匹配记录" : `共 ${count} 笔 · 本页 1-${count}`;
const empty = $("#flowEmpty");
if (empty) empty.hidden = count > 0;
const sum = $("#flowSum");
if (sum) {
let inflow = 0, outflow = 0;
$$("tbody tr", table).forEach((row) => {
if (row.hidden) return;
const amount = Number(($("td:last-child", row)?.textContent || "").replace(/[^\d.]/g, ""));
if (row.dataset.dir === "收") inflow += amount; else outflow += amount;
});
sum.innerHTML = count ? `收 <span class="amt-in">+¥ ${formatCurrency(inflow)}</span> · 付 <span class="amt-out">-¥ ${formatCurrency(outflow)}</span>` : "";
}
showToast("查询完成", `当前显示 ${count} 笔流水`);
}
function exportFlows() {
const table = $("#flowTable");
const rows = visibleRows(table);
const headers = ["交易日期", "公司", "银行账户", "方向", "对方户名", "摘要", "银行流水号", "归集状态", "金额", "导入批次", "源行定位"];
const records = rows.map((row, index) => {
const cells = $$('td', row).map((cell) => cell.innerText.replace(/\n/g, " ").trim());
const imp = `IMP-DEMO-${String(index + 1).padStart(3, "0")}`;
const loc = `Sheet1!R${index + 8}`;
const companyName = state.me?.company_name || "本公司";
if (portal === "admin") return [...cells.slice(0, 9), imp, loc];
return [cells[0], companyName, cells[1], ...cells.slice(2), imp, loc];
});
const csv = [headers, ...records].map((record) => record.map((value) => `"${String(value ?? "").replace(/"/g, '""')}"`).join(",")).join("\r\n");
const link = document.createElement("a");
link.href = URL.createObjectURL(new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" }));
link.download = `${portal === "admin" ? "集团" : (state.me?.company_name || "本公司")}银行流水_202607.csv`;
link.click();
URL.revokeObjectURL(link.href);
showToast("导出已生成", `共 ${rows.length} 笔,已保留银行标识与源行定位`, "success");
}
function initFlowTools() {
$("#applyFlowFilters")?.addEventListener("click", filterFlows);
$("#exportFlows")?.addEventListener("click", exportFlows);
renderFlowRows();
$("#resetFlowFilters")?.addEventListener("click", () => {
const company = $("#flowCompany");
if (company) company.value = "全部公司";
$("#flowBank").value = "全部银行";
$("#flowAccount").value = "全部账户";
$("#flowStart").value = "2026-07-01";
$("#flowEnd").value = portal === "admin" ? "2026-07-31" : "2026-08-20";
$("#flowKeyword").value = "";
filterFlows();
});
$("#flowTable tbody")?.addEventListener("click", (event) => {
const row = event.target.closest("tr[data-flow-idx]");
if (row) openFlowDetail(Number(row.dataset.flowIdx));
});
$("#tx-modal-close")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open"));
$("#tx-modal-ok")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open"));
$("#tx-modal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); });
}
function resetUpload() {
state.selectedFile = null;
state.parseResult = null;
$("#uploadForm")?.reset();
if ($("#filePreview")) $("#filePreview").hidden = true;
if ($("#parseResult")) $("#parseResult").hidden = true;
if ($("#sheetReview")) $("#sheetReview").hidden = true;
if ($("#sheetList")) $("#sheetList").replaceChildren();
if ($("#dropzone")) $("#dropzone").hidden = false;
if ($("#parseButton")) {
$("#parseButton").disabled = true;
$("#parseButton span").textContent = "开始解析";
delete $("#parseButton").dataset.stage;
}
}
function updateParseButton() {
const button = $("#parseButton");
if (button) button.disabled = !(state.selectedFile && $("#accountSelect").value);
}
function acceptFile(file) {
if (!file) return;
const extension = file.name.split(".").pop().toLowerCase();
if (!["xls", "xlsx"].includes(extension)) {
showToast("文件格式不支持", "请选择银行导出的 .xls 或 .xlsx 文件", "danger");
return;
}
state.selectedFile = file;
state.parseResult = null;
$("#fileName").textContent = file.name;
$("#fileMeta").textContent = `${(file.size / 1024).toFixed(1)} KB · 等待表头识别`;
$("#filePreview").hidden = false;
$("#dropzone").hidden = true;
$("#parseResult").hidden = true;
delete $("#parseButton").dataset.stage;
updateParseButton();
}
async function parseFile() {
const formData = new FormData();
formData.append("file", state.selectedFile);
const selectedAccount = $("#accountSelect")?.selectedOptions?.[0];
if (selectedAccount?.dataset.accountId) {
formData.append("bank_account_id", selectedAccount.dataset.accountId);
}
let result;
let parsed = false;
try {
const response = await fetch("/api/parse", { method: "POST", body: formData });
if (response.status === 401 || response.status === 403) {
window.location.href = "index.html";
return;
}
result = await response.json();
parsed = response.ok && ["parsed", "duplicate"].includes(result.status);
} catch {
result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" };
}
state.parseResult = result;
renderParseResult(result, parsed);
}
function renderParseResult(result, parsed) {
const panel = $("#parseResult");
if (!panel) return;
const sheets = Array.isArray(result.sheets) ? result.sheets : [];
const duplicated = result.status === "duplicate";
const opaque = duplicated && !sheets.length;
panel.classList.toggle("info", parsed);
panel.classList.toggle("warn", !parsed);
const parseIcon = $("use", panel);
if (parseIcon) parseIcon.setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
$("#parseTitle").textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
const pendingCount = sheets.filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
const exceptionCount = sheets.filter((s) => s.outcome === "exception").length;
const ignoredCount = sheets.filter((s) => s.outcome === "ignored" || s.review_status === "ignored").length;
let summary;
if (opaque) {
summary = "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。";
} else if (sheets.length) {
summary = `${sheets.length} 个工作表:${pendingCount} 个待确认${exceptionCount ? `、${exceptionCount} 个异常` : ""}${ignoredCount ? `、${ignoredCount} 个忽略` : ""}。解析成功不等于业务确认。`;
} else {
summary = `${result.message || ""} 系统不会猜测模板或自动入账。`;
}
$("#parseSummary").textContent = summary;
panel.hidden = false;
renderSheetList(sheets, result.batch_id);
const button = $("#parseButton");
button.dataset.stage = "confirm";
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 = `<span class="pill ${pillClass(coverageStatus.className)}">${coverageStatus.label}</span>`;
const parseState = document.createElement("td");
const statusParts = [];
if (batch.exception_sheets > 0) statusParts.push(`${batch.exception_sheets} 个异常`);
if (batch.ignored_sheets > 0) statusParts.push(`${batch.ignored_sheets} 个忽略`);
if (statusParts.length) {
parseState.innerHTML = `<span class="pill pill-warn">${statusParts.join("、")}</span>`;
} else {
parseState.innerHTML = '<span class="pill pill-success">解析成功</span>';
}
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 = '<button type="button" class="btn btn-sm" data-batch-view>查看</button>';
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 = '<div class="e-title">暂无待确认单边流水</div><div>刷新或重登后仍以服务端权威状态为准</div>';
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 = `
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">确认单边流水 · ${formatWorkspaceAmount(event.amount, event.currency)}</div>
<div class="lr-sub">${event.counterparty_company_name || "对方待指定"} · ${event.effective_at || "—"}</div>
</div>
<button class="btn btn-sm" data-view-link="reconcile">去确认</button>`;
list.append(row);
});
}
}
const foot = $("#workspaceTodoFoot");
if (foot) {
foot.innerHTML = total
? `<span>处理完 ${total} 笔单边流水后,工作台数字与列表将同步归零</span>`
: "<span>单边流水待办已完成</span>";
}
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 `<option value="${p.id}"${selected}>${p.name}</option>`;
})
.join("");
card.innerHTML = `
<div class="card-head">
<span class="card-title">${event.effective_at || "—"} · <span class="num">${amountText}</span>
<span class="sub">对方:${peerName}</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>事件编号</dt><dd class="num">${event.event_id}</dd>
<dt>状态</dt><dd>${event.status || event.classification || "待确认"}</dd>
<dt>金额</dt><dd class="num">${amountText}</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>确认对方公司</label>
<select class="select" data-counterparty-select>
<option value="">请选择对方公司</option>
${options}
</select>
<span class="hint">确认后按现有单边确认规则锁定对方参与方;工作台数字以服务端权威状态重拉。</span>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled>确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>`;
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 = `<div class="lr-main"><div class="lr-title">${amountText} · 已确认对方 ${select.selectedOptions[0]?.textContent || ""}</div></div><span class="pill pill-success lr-side">已匹配</span>`;
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 = '<div class="e-title">单边流水已全部匹配</div><div>确认结果已同步至工作台权威待办</div>';
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 = `<span class="pill pill-success">已确认 · ${subject}</span>`;
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 initNotifications() {
const tabs = $("#notice-tabs");
const list = $("#notice-list");
if (!tabs || !list) return;
const rows = $$(".list-row", list);
const emptyBox = $("#notice-empty");
let currentFilter = "all";
const STATUS_PILL = { unread: "pill-danger", doing: "pill-warn", done: "pill-success" };
const STATUS_LABEL = { unread: "未读", doing: "处理中", done: "已完成" };
function counts() {
const c = { all: rows.length, unread: 0, doing: 0, done: 0 };
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
return c;
}
function refreshCounts() {
const c = counts();
$("#count-all").textContent = c.all;
$("#count-unread").textContent = c.unread;
$("#count-doing").textContent = c.doing;
$("#count-done").textContent = c.done;
const navBadge = $('.side-nav a[data-view="notifications"] .nav-badge');
if (navBadge) {
navBadge.textContent = c.unread;
navBadge.style.display = c.unread ? "" : "none";
}
}
function applyFilter() {
let visible = 0;
rows.forEach((row) => {
const show = currentFilter === "all" || row.getAttribute("data-status") === currentFilter;
row.style.display = show ? "" : "none";
if (show) visible += 1;
});
emptyBox.style.display = visible === 0 ? "" : "none";
}
function setStatus(row, status) {
row.setAttribute("data-status", status);
const pill = $(".pill", row);
if (pill) {
pill.className = `pill ${STATUS_PILL[status]}`;
pill.textContent = STATUS_LABEL[status];
}
}
tabs.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$$("button", tabs).forEach((b) => { b.classList.remove("active"); b.setAttribute("aria-pressed", "false"); });
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
currentFilter = btn.dataset.filter;
applyFilter();
});
list.addEventListener("click", (event) => {
const btn = event.target.closest(".btn-mark-read");
if (!btn) return;
const row = btn.closest(".list-row");
setStatus(row, "done");
btn.remove();
refreshCounts();
applyFilter();
});
$("#mark-all-read")?.addEventListener("click", () => {
rows.forEach((row) => {
if (row.getAttribute("data-status") !== "unread") return;
setStatus(row, "done");
$(".btn-mark-read", row)?.remove();
});
refreshCounts();
applyFilter();
showToast("通知已全部标为已读", "", "success");
});
refreshCounts();
applyFilter();
}
function openAccountDetail(account) {
const meta = companyAccountMeta(account.status);
$("#ad-title").textContent = `${account.bank_name || ""} · 尾号 ${accountTail(account.account_number_masked)}`;
$("#ad-sub").textContent = "本公司 · 登记账户明细";
$("#ad-bank").textContent = account.bank_name || "—";
$("#ad-tail").textContent = `尾号 ${accountTail(account.account_number_masked)}`;
$("#ad-type").textContent = account.account_type || "—";
$("#ad-branch").textContent = account.account_name || "—";
$("#ad-reg").textContent = String(account.created_at || "").slice(0, 10) || "—";
$("#ad-status").textContent = meta.status.label;
$("#ad-audit").textContent = meta.audit.label;
$("#ad-purpose").textContent = "—";
$("#ad-effective").textContent = account.effective_from || "待审核确定";
$("#ad-range").textContent = account.usable ? "尚未上传" : "—";
$("#ad-reason").textContent = account.status === "returned" && account.review_reason ? account.review_reason : "—";
$("#modal-account-detail")?.classList.add("open");
}
function yuanToWan(value) {
const n = Number(value);
if (!Number.isFinite(n)) return null;
return n / 10000;
}
function formatWan(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}<span class="unit">万元</span>`;
const sign = wan > 0 ? "+" : wan < 0 ? "" : "";
return `${sign}${abs}<span class="unit">万元</span>`;
}
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 = formatWan(confirmed.inflow_total, { signed: true });
if (outflow) {
outflow.innerHTML = formatWan(
confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null,
{ signed: true },
);
// formatWan with negative value already adds ; ensure unit
if (confirmed.outflow_total != null) {
const wan = yuanToWan(confirmed.outflow_total);
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
outflow.innerHTML = `${abs}<span class="unit">万元</span>`;
}
}
if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`;
if (net) {
net.className = `ms-value ${netMeta.signedClass}`.trim();
net.innerHTML = formatWan(confirmed.net_change, { signed: true });
}
if (pendingEl) {
const pCount = Number(pending.count) || 0;
pendingEl.innerHTML = pCount
? `${formatWan(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
: `0.00<span class="unit">万元 · 0 笔</span>`;
}
}
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 = `
<div class="card stat-card"><div class="stat-label">往来公司数</div><div class="stat-value">0<span class="unit">家</span></div><div class="stat-foot">${win.start || "—"} ~ ${win.end || "—"}</div></div>
<div class="card stat-card"><div class="stat-label">本期转入 · 流入</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>
<div class="card stat-card"><div class="stat-label">本期转出 · 流出</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>
<div class="card stat-card"><div class="stat-label">${netLabelForWindow(win)}</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>`;
}
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}<span class="unit">家</span>`;
if (inflow) inflow.innerHTML = formatWan(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}<span class="unit">万元</span>`;
}
if (netTitle) netTitle.textContent = netLabelForWindow(win);
if (net) {
const tag = netMeta.label !== "持平"
? ` <span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span>`
: "";
net.innerHTML = `${formatWan(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 = formatWan(confirmed.net_change, { signed: true });
if (tfPendingCount) tfPendingCount.textContent = `${pCount} 笔`;
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0);
const tbody = $("#transfersCpBody");
const tfoot = $("#transfersCpFoot");
if (!tbody) return;
if (!cps.length) {
tbody.innerHTML = `<tr><td colspan="7"><div class="empty" style="border:0;padding:24px 0;"><div class="e-title">暂无对方公司汇总</div></div></td></tr>`;
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
? `<span class="pill pill-warn">${pendingN} 笔待确认</span>`
: `<span class="pill pill-success">全部已确认</span>`;
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 `<tr class="clickable" data-cp-id="${cp.company_id}" data-cp-name="${String(cp.company_name || "").replace(/"/g, "&quot;")}">
<td class="cell-main">${cp.company_name || "—"}</td>
<td class="num-col amt-in">${inText}</td>
<td class="num-col amt-out">${outText}</td>
<td class="num-col">${netText}</td>
<td><span class="xfer-dir-tag ${meta.className}">${meta.label}</span></td>
<td>${pendingPill}</td>
<td class="num">${last}</td>
</tr>`;
}).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 = `<tr>
<td>合计</td>
<td class="num-col amt-in">${inWan === null ? "—" : `+${fmtAbs(inWan)}`}</td>
<td class="num-col amt-out">${outWan === null ? "—" : `${fmtAbs(outWan)}`}</td>
<td class="num-col">${netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "" : ""}${fmtAbs(netWan)}`}</td>
<td><span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span></td>
<td>${totalPending ? `<span class="pill pill-warn">${totalPending} 笔待确认</span>` : `<span class="pill pill-success">全部已确认</span>`}</td>
<td></td>
</tr>`;
}
}
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 = formatWan(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 })}<span class="unit">万元</span>`;
}
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 ? "+" : ""}<span class="unit">笔</span>`;
}
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 `<tr class="${pendingClass.trim()}" data-event-id="${ev.event_id}">
<td class="num">${date}</td>
<td class="wrap">${ev.summary || "—"}</td>
<td>${dir}${ev.direction === "in" ? " ↓" : ev.direction === "out" ? " ↑" : ""}</td>
<td class="num-col ${dirClass}">${amt}</td>
<td><span class="pill ${st.pill}">${st.text}</span></td>
<td><button type="button" class="btn btn-sm" data-transfer-evidence="${ev.event_id}">原始流水</button></td>
</tr>`;
}).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() {
renderCompanyManualRecords();
loadCompanyAccounts();
loadImportBatches();
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
$$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
$$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
$$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => showView("upload")));
// ── 流水导入:多步流程(选文件 → 解析 → 分 sheet 审核 → 确认) ──
$("#accountSelect")?.addEventListener("change", updateParseButton);
$("#fileInput")?.addEventListener("change", (event) => acceptFile(event.target.files[0]));
$("#removeFile")?.addEventListener("click", () => {
state.selectedFile = null;
$("#fileInput").value = "";
$("#filePreview").hidden = true;
$("#dropzone").hidden = false;
$("#parseResult").hidden = true;
updateParseButton();
});
const dropzone = $("#dropzone");
if (dropzone) {
["dragenter", "dragover"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add("dragover"); }));
["dragleave", "drop"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove("dragover"); }));
dropzone.addEventListener("click", () => $("#fileInput").click());
dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); $("#fileInput").click(); } });
dropzone.addEventListener("drop", (event) => acceptFile(event.dataTransfer.files[0]));
}
$("#uploadForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const stage = $("#parseButton").dataset.stage;
if (stage === "confirm" || stage === "done") {
const result = state.parseResult;
const parsed = result && ["parsed", "duplicate"].includes(result.status);
if (parsed && stage === "confirm") {
await confirmImport();
} else if (parsed) {
resetUpload();
showView("upload");
await loadImportBatches();
} else {
submitImportException();
}
return;
}
$("#parseButton").disabled = true;
$("#parseButton span").textContent = "正在识别表头...";
await parseFile();
});
// ── 导入批次详情弹窗 ──
$("#importRows")?.addEventListener("click", (event) => {
const btn = event.target.closest("[data-batch-view]");
if (!btn) return;
const tr = btn.closest("tr");
const batch = (state.batches || []).find((b) => String(b.id) === String(tr?.dataset.batchId));
if (!batch) return;
$("#mb-sub").textContent = `IMP-${String(batch.id).padStart(6, "0")} · ${batch.original_filename || ""}`;
$("#mb-id").textContent = `IMP-${String(batch.id).padStart(6, "0")}`;
$("#mb-bank").textContent = batch.bank_name || "—";
$("#mb-period").textContent = batch.period_start && batch.period_end ? `${batch.period_start} ~ ${batch.period_end}` : "—";
$("#mb-count").textContent = `${batch.confirmed_transactions ?? 0} 条`;
$("#mb-cover").textContent = batch.status === "exception" ? "未导入" : batch.pending_sheets > 0 ? `${batch.pending_sheets} 个待确认` : batch.confirmed_sheets > 0 ? "已确认" : "待处理";
$("#mb-parse").textContent = batch.exception_sheets > 0 ? `${batch.exception_sheets} 个异常` : batch.ignored_sheets > 0 ? `${batch.ignored_sheets} 个忽略` : "解析成功";
$("#mb-time").textContent = String(batch.created_at || "").slice(0, 16).replace("T", " ");
openModal("modal-batch");
});
// ── 手工记录:提交 + 撤回 ──
$("#manualEntryForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const evidence = data.get("evidence");
if (evidence instanceof File && evidence.size > 20 * 1024 * 1024) {
showToast("证明附件超过限制", "请选择不超过 20 MB 的文件", "warn");
return;
}
const records = readStoredRecords(storageKeys.manual);
const record = {
id: `MR-${Date.now().toString().slice(-10)}`,
company: "A公司",
transactionDate: String(data.get("transactionDate")),
direction: String(data.get("direction")),
amount: Number(data.get("amount")),
sourceAccount: String(data.get("sourceAccount")),
counterpartyType: String(data.get("counterpartyType")),
counterparty: String(data.get("counterparty")).trim(),
counterpartyAccount: String(data.get("counterpartyAccount") || "").trim(),
subject: String(data.get("subject")),
summary: String(data.get("summary")).trim(),
remark: String(data.get("remark")).trim(),
bankReference: String(data.get("bankReference") || "").trim(),
evidenceName: evidence instanceof File ? evidence.name : "",
status: "待管理复核",
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
};
records.push(record);
if (!writeStoredRecords(storageKeys.manual, records)) return;
renderCompanyManualRecords();
form.reset();
showToast("手工记录已提交", "管理员复核前不会纳入公司间往来计算", "success");
});
let manualWithdrawRow = null;
$("#manualRecordRows")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-action='withdraw']");
if (!btn) return;
const tr = btn.closest("tr");
manualWithdrawRow = tr;
const cells = tr.cells;
$("#wd-date").textContent = cells[0]?.textContent.trim() || "—";
$("#wd-peer").textContent = cells[2]?.textContent.trim() || "—";
$("#wd-amount").textContent = cells[4]?.textContent.trim() || "—";
$("#wd-summary").textContent = cells[5]?.textContent.trim() || "—";
openModal("withdraw-modal");
});
$("#wd-confirm")?.addEventListener("click", () => {
if (manualWithdrawRow) {
const id = manualWithdrawRow.dataset.storedRecord;
if (id) {
const records = readStoredRecords(storageKeys.manual).filter((r) => r.id !== id);
writeStoredRecords(storageKeys.manual, records);
} else {
manualWithdrawRow.remove();
}
manualWithdrawRow = null;
}
closeModal("withdraw-modal");
renderCompanyManualRecords();
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
});
// ── 转账往来(方案 A)──
initTransfers();
// ── 往来确认 + 工作台权威待办 ──
initReconcile();
(async () => {
await loadCompanyWorkspace();
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 data = new FormData(event.currentTarget);
const response = await fetch("/api/company/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bank_name: String(data.get("bank") || "").trim(),
account_type: String(data.get("type") || ""),
account_number: String(data.get("accountNumber") || ""),
start_date: String(data.get("startDate") || ""),
}),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("账户登记失败", result?.message || "请稍后重试", "danger");
return;
}
closeModal("accountDialog");
event.currentTarget.reset();
await loadCompanyAccounts();
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success");
});
}
if (portal === "entry") {
initEntry();
} else {
initAuthGuard().then((allowed) => {
if (!allowed) return;
initShell();
initFlowTools();
if (portal === "admin") {
initPairQueries();
initAdmin();
} else {
initCompany();
}
});
}