Files
caiwuzongzhang/web/app.js
T

2426 lines
117 KiB
JavaScript
Raw 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: "流水管理", balances: "往来余额", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
const storageKeys = {
manual: "ledger-demo-manual-records",
};
const accountStatusLabels = {
pending: "待复核",
active: "已启用",
returned: "已退回",
disabled: "已停用",
};
const state = {
currentView: portal === "admin" ? "dashboard" : "workspace",
selectedFile: null,
parseResult: null,
};
const motionQuery = 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-heading > *",
".metric-card",
".period-ribbon",
".company-alert",
".admin-dashboard-grid > *",
".company-dashboard-grid > *",
".company-ledger-panel",
".query-band",
".filter-bar",
".filter-grid",
".pair-report",
".panel",
".work-progress",
".reconcile-summary",
".account-directory > article",
];
const elements = [...new Set(selectors.flatMap((selector) => [...view.querySelectorAll(selector)]))]
.filter((element) => !element.closest(".panel") || element.matches(".panel"));
elements.forEach((element, index) => {
element.getAnimations().forEach((animation) => animation.cancel());
// metric-card 的 3D 倾斜与悬停倾斜由 CSS 控制,入场动画只做淡入,
// 否则 fill:both 的 translateY(0) 会覆盖 CSS 的 transform,导致倾斜失效。
const keyframes = element.classList.contains("metric-card")
? [{ opacity: 0 }, { opacity: 1 }]
: [
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
{ opacity: 1, transform: "translateY(0)" },
];
element.animate(keyframes, {
duration: 440,
delay: Math.min(index * 38, 260),
easing: "cubic-bezier(.22,1,.36,1)",
fill: "both",
});
});
}
function initMotion() {
if (motionQuery.matches) return;
animateView($(".app-view.is-active"), { initial: true });
$$(".nav-item", $("#sidebar") || document).forEach((item, index) => {
item.animate(
[{ opacity: 0, transform: "translateX(-8px)" }, { opacity: 1, transform: "translateX(0)" }],
{ duration: 360, delay: 90 + index * 28, easing: "cubic-bezier(.22,1,.36,1)", fill: "both" },
);
});
$$(".company-ledger").forEach((ledger) => {
ledger.addEventListener("toggle", () => {
if (!ledger.open) return;
$(".ledger-breakdown", ledger)?.animate(
[{ opacity: 0, transform: "translateY(-8px)" }, { opacity: 1, transform: "translateY(0)" }],
{ duration: 260, easing: "cubic-bezier(.22,1,.36,1)" },
);
});
});
}
function readStoredRecords(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || "[]");
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
function writeStoredRecords(key, records) {
try {
localStorage.setItem(key, JSON.stringify(records));
return true;
} catch {
showToast("本机演示数据保存失败", "请检查浏览器是否允许本地存储");
return false;
}
}
function recordStatus(status) {
if (["已启用", "已确认"].includes(status)) return { className: "success", label: status };
if (status === "已退回") return { className: "danger", label: status };
if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status };
return { className: "warning", label: status || "待复核" };
}
function accountStatusLabel(status) {
return accountStatusLabels[status] || "待复核";
}
function accountTail(masked) {
return String(masked || "").replace(/^\*+/, "");
}
function formatCurrency(value) {
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function showToast(title, detail = "") {
const region = $("#toastRegion");
if (!region) return;
const toast = document.createElement("div");
toast.className = "toast";
const heading = document.createElement("strong");
heading.textContent = title;
toast.append(heading);
if (detail) {
const description = document.createElement("small");
description.textContent = detail;
toast.append(description);
}
region.append(toast);
window.setTimeout(() => toast.remove(), 3400);
}
function closeNavigation({ restoreFocus = false } = {}) {
const sidebar = $("#sidebar");
if (!sidebar) return;
const wasOpen = sidebar.classList.contains("is-open");
sidebar.classList.remove("is-open");
$$(".menu-button").forEach((button) => {
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "打开导航");
});
if (restoreFocus && wasOpen) $(".menu-button")?.focus();
}
function showView(view) {
if (!viewNames[view]) return;
const navigationWasOpen = $("#sidebar")?.classList.contains("is-open");
state.currentView = view;
$$(".app-view").forEach((page) => page.classList.toggle("is-active", page.dataset.page === view));
$$(".nav-item[data-view]").forEach((item) => {
const active = item.dataset.view === view;
item.classList.toggle("is-active", active);
if (active) item.setAttribute("aria-current", "page");
else item.removeAttribute("aria-current");
});
const title = $("#currentViewName");
if (title) title.textContent = viewNames[view];
closeNavigation({ restoreFocus: navigationWasOpen });
const activeView = $(`.app-view[data-page="${view}"]`);
requestAnimationFrame(() => animateView(activeView));
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
}
const detailContent = {
"gap-a": { tag: ["danger", "高风险"], title: "A公司 · 工行账户断档", desc: "工商银行 9481 缺少 07.01—07.21 流水,已影响 7 月结账。", fields: [["公司", "A公司"], ["账户", "工商银行 · 9481"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账 · 账户覆盖 · 双边匹配"], ["当前状态", "已逾期 2 天"]], tip: "建议先向 A公司出纳发送补传提醒,补齐后在审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] },
"match-bd": { tag: ["warning", "中风险"], title: "B公司 ↔ D公司 · 单边待匹配", desc: "D公司侧流水已到,B公司侧尚未确认,合计 312.00 万元。", fields: [["本方", "B公司"], ["对方", "D公司"], ["笔数 / 金额", "6 笔 · 312.00 万元"], ["候选情况", "金额与日期存在 2 个候选"], ["当前状态", "今日新增"]], tip: "建议按账号优先核对候选流水,金额与日期相同者先确认。", action: ["去审核中心匹配", "audit"] },
"calib-f": { tag: ["warning", "中风险"], title: "F公司 · 起算区间待校准", desc: "01.01—01.16 无银行流水覆盖,公司已提交无业务说明。", fields: [["公司", "F公司"], ["账户", "农业银行 · 3650"], ["无覆盖期间", "2026.01.01—01.16"], ["现有依据", "公司已提交无业务说明"], ["当前状态", "待公司确认"]], tip: "无业务说明属于审计证据,复核通过后该区间标记为已校准,不生成银行流水。", action: ["去审核中心复核", "audit"] },
"task-upload": { tag: ["danger", "最紧急"], title: "补传工商银行流水", desc: "账户尾号 9481 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户", "工商银行 · 9481"], ["缺少期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月账户覆盖与双边匹配"], ["截止", "08.05 集团结账日前"]], tip: "从工商银行网银导出 7 月流水后直接上传,系统会自动识别表头并重新计算匹配。", action: ["去上传流水", "upload"] },
"task-match": { tag: ["warning", "待确认"], title: "确认 1 笔单边流水", desc: "07.18 转出 280.00 万元,系统找到 2 个对方候选。", fields: [["对方", "B公司"], ["日期 / 金额", "07.18 · 280.00 万元"], ["候选", "工商银行 9481(推荐)· 建设银行 2046"], ["核对点", "摘要与账号是否一致"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] },
"task-subject": { tag: ["warning", "待确认"], title: "确认往来科目", desc: "06.27 转出 600.00 万元,规则无法区分应收与其他应收。", fields: [["对方", "C公司"], ["日期 / 金额", "06.27 · 600.00 万元"], ["待确认", "应收 或 其他应收"], ["摘要", "资金调拨"]], tip: "科目只按确定性规则建议,拿不准时选“其他应收”并在说明里注明依据。", action: ["去确认科目", "reconcile"] },
"task-notice": { tag: ["neutral", "提醒"], title: "阅读总账提醒", desc: "管理员要求 08.08 前完成 7 月银行流水确认。", fields: [["来自", "系统管理员 · 今天 09:30"], ["处理期限", "2026.08.08"], ["关联事项", "断档补传 · 2 项待确认往来"]], tip: "完成补传和两项确认后,再提交公司确认即可。", action: ["查看通知", "notifications"] },
"acct-citic": { tag: ["success", "连续"], title: "中信银行 · 5316", desc: "基本户 · 起算日以来流水全部连续。", fields: [["账户类型", "基本户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "今天 09:42"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
"acct-abc": { tag: ["success", "连续"], title: "农业银行 · 3650", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 08:01"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
"acct-icbc": { tag: ["danger", "断档"], title: "工商银行 · 9481", desc: "一般户 · 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户类型", "一般户"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账与双边匹配"], ["最近导入", "07.31 16:186 月批次)"]], tip: "这是最紧急的一项:补齐后系统会自动重算覆盖与匹配。", action: ["去补传流水", "upload"] },
"acct-ccb": { tag: ["success", "连续"], title: "建设银行 · 0845", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 10:03"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
};
function initDetailDrawer() {
const triggers = $$("[data-detail]");
if (!triggers.length) return;
const drawer = document.createElement("aside");
drawer.className = "detail-drawer";
drawer.id = "detailDrawer";
drawer.setAttribute("aria-label", "事项详情");
drawer.innerHTML = `<header><div><span class="status" id="detailTag"></span><h2 id="detailTitle"></h2><p class="detail-desc" id="detailDesc"></p></div><button type="button" class="icon-button" data-close-detail aria-label="关闭详情" title="关闭详情"><svg><use href="icons.svg#x"/></svg></button></header><dl class="detail-fields" id="detailFields"></dl><div class="detail-tip" id="detailTip"></div><footer><button type="button" class="button secondary" data-close-detail>关闭</button><button type="button" class="button primary" id="detailAction"></button></footer>`;
document.body.append(drawer);
let lastTrigger = null;
function closeDrawer({ restoreFocus = true } = {}) {
drawer.classList.remove("is-open");
if (restoreFocus && lastTrigger) lastTrigger.focus();
}
function openDetail(id, trigger) {
const item = detailContent[id];
if (!item) return;
lastTrigger = trigger;
const tag = $("#detailTag", drawer);
tag.className = `status ${item.tag[0]}`;
tag.textContent = item.tag[1];
$("#detailTitle", drawer).textContent = item.title;
$("#detailDesc", drawer).textContent = item.desc;
const fields = $("#detailFields", drawer);
fields.replaceChildren(...item.fields.map(([label, value]) => {
const row = document.createElement("div");
const dt = document.createElement("dt"); dt.textContent = label;
const dd = document.createElement("dd"); dd.textContent = value;
row.append(dt, dd);
return row;
}));
const tip = $("#detailTip", drawer);
tip.replaceChildren();
const tipHeading = document.createElement("strong"); tipHeading.textContent = "处理建议";
tip.append(tipHeading, document.createTextNode(item.tip));
const action = $("#detailAction", drawer);
action.textContent = item.action[0];
action.onclick = () => {
closeDrawer({ restoreFocus: false });
if (item.action[1] === "upload") $("[data-open-upload]")?.click();
else showView(item.action[1]);
};
drawer.classList.add("is-open");
$("[data-close-detail]", drawer).focus();
}
$$("[data-close-detail]", drawer).forEach((button) => button.addEventListener("click", () => closeDrawer()));
drawer.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.stopPropagation();
closeDrawer();
}
});
triggers.forEach((element) => {
element.addEventListener("click", (event) => {
const innerButton = event.target.closest("button");
if (innerButton && innerButton !== element) return;
openDetail(element.dataset.detail, element);
});
if (element.tagName !== "BUTTON") {
element.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDetail(element.dataset.detail, element);
}
});
}
});
}
function initEntry() {
const form = $("#loginForm");
if (!form) return;
if (!motionQuery.matches) {
[$(".entry-brand"), $(".entry-statement"), ...$$(".entry-facts > div"), form].filter(Boolean).forEach((element, index) => {
element.animate(
[{ opacity: 0, transform: "translateY(16px)" }, { opacity: 1, transform: "translateY(0)" }],
{ duration: 520, delay: index * 65, easing: "cubic-bezier(.22,1,.36,1)", fill: "both" },
);
});
}
const roleInputs = $$('input[name="role"]', form);
const username = $('input[name="username"]', form);
const password = $('input[name="password"]', form);
const action = $("#loginAction");
const errorBox = $("#loginError");
const changeSection = $("#changePassword");
let pendingRole = null;
function showError(message) {
errorBox.textContent = message;
errorBox.hidden = false;
}
function updateRole() {
const role = $('input[name="role"]:checked', form).value;
action.textContent = role === "admin" ? "进入总账管理端" : "进入公司业务端";
}
roleInputs.forEach((input) => input.addEventListener("change", updateRole));
$("#togglePassword").addEventListener("click", (event) => {
const visible = password.type === "text";
password.type = visible ? "password" : "text";
event.currentTarget.setAttribute("aria-label", visible ? "显示密码" : "隐藏密码");
event.currentTarget.title = visible ? "显示密码" : "隐藏密码";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
errorBox.hidden = true;
const role = pendingRole || $('input[name="role"]:checked', form).value;
if (pendingRole) {
const newPassword = $('input[name="new_password"]', form).value;
const confirmPassword = $('input[name="confirm_password"]', form).value;
if (newPassword !== confirmPassword) {
showError("两次输入的新密码不一致。");
return;
}
const changeResponse = await fetch("/api/password/change", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_password: password.value, new_password: newPassword }),
}).catch(() => null);
const changeResult = await changeResponse?.json().catch(() => ({}));
if (!changeResponse || !changeResponse.ok) {
showError(changeResult?.message || "修改密码失败,请稍后重试。");
return;
}
window.location.href = role === "admin" ? "admin.html" : "company.html";
return;
}
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.value.trim(), password: password.value, portal: role }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showError(result?.message || "登录服务暂时不可用,请稍后重试。");
return;
}
if (result.must_change_password) {
pendingRole = role;
changeSection.hidden = false;
action.textContent = "设置新密码并进入";
$('input[name="new_password"]', form).focus();
return;
}
window.location.href = role === "admin" ? "admin.html" : "company.html";
});
}
async function initAuthGuard() {
if (portal === "entry") return true;
try {
const response = await fetch("/api/me");
if (response.status === 401) {
window.location.href = "index.html";
return false;
}
const me = await response.json();
if (!response.ok || me.role !== portal) {
window.location.href = "index.html";
return false;
}
state.me = me;
applyCompanyIdentity(me);
return true;
} catch {
return true;
}
}
function applyCompanyIdentity(me) {
// The company portal always shows the session-bound company, never a
// hard-coded one.
if (portal !== "company" || !me?.company_name) return;
const context = $(".company-context");
if (context) {
const mark = $("span", context);
if (mark) mark.textContent = me.company_name.slice(0, 1);
const name = $("strong", context);
if (name) name.textContent = me.company_name;
}
}
function initShell() {
$$(".page-heading").forEach((heading) => {
if (heading.querySelector(".menu-button")) return;
const button = document.createElement("button");
button.type = "button";
button.className = "icon-button menu-button";
button.setAttribute("aria-label", "打开导航");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-controls", "sidebar");
button.title = "打开导航";
button.innerHTML = '<svg><use href="icons.svg#menu"></use></svg>';
heading.prepend(button);
});
$$(".menu-button").forEach((menuButton) => {
menuButton.addEventListener("click", () => {
const open = $("#sidebar").classList.toggle("is-open");
menuButton.setAttribute("aria-expanded", String(open));
menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航");
});
});
$$(".nav-item").forEach((item) => {
const label = $("span", item)?.textContent.trim();
if (label) {
item.setAttribute("aria-label", label);
item.title = label;
}
});
$$("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
$$("[data-view-link]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.viewLink)));
$$('a.nav-item[href="index.html"]').forEach((link) => link.addEventListener("click", async (event) => {
event.preventDefault();
try {
await fetch("/api/logout", { method: "POST" });
} catch { /* 网络异常时仍然回到登录页 */ }
window.location.href = "index.html";
}));
$$("[data-metric-link]").forEach((card) => {
const activate = () => showView(card.dataset.metricLink);
card.addEventListener("click", activate);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
});
$$("[data-metric-action=\"upload\"]").forEach((card) => {
const activate = () => $("[data-open-upload]")?.click();
card.addEventListener("click", activate);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
});
// Delegated: company table rows are rendered from the API after init.
document.addEventListener("click", (event) => {
const toastButton = event.target.closest("[data-toast]");
if (toastButton) showToast(toastButton.dataset.toast);
});
$(".nav-item[data-view].is-active")?.setAttribute("aria-current", "page");
$("#globalSearch")?.addEventListener("input", (event) => {
const view = $(`.app-view[data-page="${state.currentView}"]`);
const query = event.target.value.trim().toLowerCase();
$$(".data-table tbody tr, .company-ledger, .notification-list article, .account-directory article", view).forEach((item) => {
item.hidden = query ? !item.textContent.toLowerCase().includes(query) : false;
});
});
document.addEventListener("click", (event) => {
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true });
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeNavigation({ restoreFocus: true });
});
initDetailDrawer();
initMotion();
}
// ---------------------------------------------------------------------------
// B-44 intercompany balances: directory, pair drill-down and evidence drawer
// ---------------------------------------------------------------------------
const b44 = {
subjectLabels: { receivable: "应收", payable: "应付", other_receivable: "其他应收", other_payable: "其他应付" },
subjectCodes: { 应收: "receivable", 应付: "payable", 其他应收: "other_receivable", 其他应付: "other_payable" },
reasonLabels: { subject_review: "待确认科目", unmatched_single: "单边未决", manual_pending: "手工记录待审" },
from: "2026-01-01",
cutoff: "2026-07-31",
currency: "",
companyNames: new Map(),
};
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
}
function fmtMoney(value) {
const num = Number(value);
return Number.isFinite(num) ? num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—";
}
function fmtDate(iso) {
const text = String(iso || "");
return text.slice(0, 10).replace(/-/g, ".");
}
function b44Status(item) {
if (item.opening?.status !== "unavailable") {
return { label: "期末余额", className: "success" };
}
return { label: "期间净变动", className: "neutral" };
}
function subjectOf(event, companyId) {
if (event.perspective_company_id === companyId) return event.subject_code;
return { receivable: "payable", payable: "receivable", other_receivable: "other_payable", other_payable: "other_receivable" }[event.subject_code] || null;
}
function directionChip(direction) {
const label = direction === "outgoing" ? "转出" : "转入";
const arrow = direction === "outgoing" ? "→" : "←";
return `<span class="direction-chip" aria-label="${label}">${arrow} ${label}</span>`;
}
function eventStateChip(state) {
if (state === "confirmed") return '<span class="status success">已确认</span>';
if (state === "pending_subject") return '<span class="status info">待确认科目</span>';
return '<span class="status warning">未决</span>';
}
function postingLabel(event) {
if (event.posting_kind === "reversal") return "冲销";
if (event.posting_kind === "adjustment") return "调整";
return "正常";
}
function unresolvedText(unresolved) {
const gross = Number(unresolved?.gross_amount || 0);
const count = unresolved?.count || 0;
if (!count) return { html: '<span class="status neutral">未决 0.00</span>', active: false };
const reasons = Object.entries(unresolved.by_reason || {})
.map(([key, value]) => `${b44.reasonLabels[key] || key} ${fmtMoney(value.gross_amount)} · ${value.count} 笔`)
.join("");
return {
html: `<span class="status warning">未决 ${fmtMoney(gross)} · ${count} 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px" title="${esc(reasons)}">${esc(reasons)}</small>`,
active: true,
};
}
function drawerState() {
const drawer = $("#evidenceDrawer");
const scrim = $("#drawerScrim");
let trigger = null;
const layerStack = [];
function open(triggerElement) {
if (!drawer) return;
trigger = triggerElement || trigger;
drawer.classList.add("is-open");
drawer.setAttribute("aria-hidden", "false");
scrim?.classList.add("is-open");
requestAnimationFrame(() => $("#drawerBody", drawer)?.focus({ preventScroll: true }));
}
function close({ restoreFocus = true } = {}) {
if (!drawer) return;
drawer.classList.remove("is-open");
drawer.setAttribute("aria-hidden", "true");
scrim?.classList.remove("is-open");
layerStack.length = 0;
if (restoreFocus && trigger) trigger.focus();
}
function renderLayer(title, crumbs, html, onMount) {
if (!drawer) return;
$("#drawerTitle", drawer).textContent = title;
const breadcrumb = $("#drawerBreadcrumb", drawer);
breadcrumb.replaceChildren();
crumbs.forEach((crumb, index) => {
if (index) breadcrumb.append(document.createTextNode("/"));
if (crumb.action) {
const button = document.createElement("button");
button.type = "button";
button.textContent = crumb.label;
button.addEventListener("click", crumb.action);
breadcrumb.append(button);
} else {
const span = document.createElement("span");
span.textContent = crumb.label;
breadcrumb.append(span);
}
});
$("#drawerBody", drawer).innerHTML = html;
if (typeof onMount === "function") onMount($("#drawerBody", drawer));
}
$$("[data-close-drawer]").forEach((button) => button.addEventListener("click", () => close()));
drawer?.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.stopPropagation();
close();
}
});
return { open, close, renderLayer };
}
const drawer = drawerState();
function regionSkeleton(rows = 3) {
return Array.from({ length: rows }, () => '<div class="skeleton-row skeleton-block"><span></span><span></span><span></span><span></span><span></span></div>').join("");
}
function regionError(container, message, retry) {
if (!container) return;
container.innerHTML = `<div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>余额计算暂时不可用</strong><p>${esc(message)}</p><button type="button" class="button secondary" data-retry>重试</button></div>`;
container.querySelector("[data-retry]")?.addEventListener("click", retry);
}
function regionEmpty(container, title, hint, action) {
if (!container) return;
container.innerHTML = `<div class="state-panel"><svg class="state-icon"><use href="icons.svg#inbox"/></svg><strong>${esc(title)}</strong><p>${esc(hint)}</p>${action || ""}</div>`;
}
async function apiJson(url, options) {
const response = await fetch(url, options).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return null;
}
const body = await response?.json().catch(() => null);
if (!response || !response.ok) {
throw new Error(body?.message || "请求失败");
}
return body;
}
function balanceQuery() {
return `from=${encodeURIComponent(b44.from)}&cutoff=${encodeURIComponent(b44.cutoff)}${b44.currency ? `&currency=${encodeURIComponent(b44.currency)}` : ""}`;
}
// --- Admin: company balance directory --------------------------------------
async function loadAdminBalances(container) {
if (!container) return;
container.setAttribute("aria-busy", "true");
container.innerHTML = regionSkeleton(3);
try {
const data = await apiJson(`/api/admin/intercompany/balances?${balanceQuery()}`);
container.setAttribute("aria-busy", "false");
if (!data.items.length) {
regionEmpty(container, "该区间无往来事件", "请调整统计区间后重试", '<button type="button" class="button secondary" data-adjust-window>调整统计区间</button>');
container.querySelector("[data-adjust-window]")?.addEventListener("click", () => document.querySelector("[data-balance-form] [name='from']")?.focus());
return;
}
renderBalanceDirectory(container, data.items);
} catch (error) {
container.setAttribute("aria-busy", "false");
regionError(container, error.message, () => loadAdminBalances(container));
}
}
function renderBalanceDirectory(container, items) {
container.replaceChildren(...items.map((item) => {
const details = document.createElement("details");
details.className = "company-ledger is-balances";
details.dataset.companyId = item.company_id;
const direction = item.result.direction === "receivable"
? { className: "success", label: "应收" }
: item.result.direction === "payable"
? { className: "danger", label: "应付" }
: { className: "neutral", label: "持平" };
const unresolved = unresolvedText(item.unresolved);
const name = item.company_name || "未命名公司";
const summary = document.createElement("summary");
summary.innerHTML = `
<span class="company-name"><i>${esc(name.slice(0, 1))}</i><b>${esc(name)}</b><small class="currency-tag">${esc(item.currency)}</small></span>
<strong class="amount debit">${fmtMoney(item.period.debit)}</strong>
<strong class="amount credit">${fmtMoney(item.period.credit)}</strong>
<span class="ledger-result"><em class="status ${direction.className}">${direction.label}</em><b>${fmtMoney(item.result.signed_amount)}</b></span>
<span class="ledger-unresolved ${unresolved.active ? "is-active" : "is-empty"}">${unresolved.html}</span>
<span class="ledger-cutoff ledger-hide-sm">截止 ${fmtDate(item.window.cutoff)}</span>
<svg><use href="icons.svg#chevron-down"/></svg>`;
details.append(summary);
const breakdown = document.createElement("div");
breakdown.className = "ledger-breakdown";
breakdown.setAttribute("aria-live", "polite");
breakdown.innerHTML = '<div class="skeleton-row skeleton-block"><span></span><span></span><span></span></div>';
details.append(breakdown);
details.addEventListener("toggle", () => {
if (!details.open || details.dataset.loaded) return;
details.dataset.loaded = "1";
loadCompanyBreakdown(breakdown, item, () => details.open = false);
});
return details;
}));
}
async function loadCompanyBreakdown(container, item, close) {
try {
const data = await apiJson(`/api/admin/intercompany/events?company_id=${item.company_id}&${balanceQuery()}`);
const debit = [];
const credit = [];
const byPair = new Map();
for (const event of data.items) {
if (event.state !== "confirmed") continue;
const isPayer = event.payer_company_id === item.company_id;
const counterpartyId = isPayer ? event.payee_company_id : event.payer_company_id;
const counterpartyName = isPayer ? event.payee_company_name : event.payer_company_name;
const subject = subjectOf(event, item.company_id);
const key = `${counterpartyId}|${subject}`;
const bucket = byPair.get(key) || { counterpartyId, counterpartyName, subject, count: 0, amount: 0, isPayer };
bucket.count += 1;
bucket.amount += Number(event.amount);
byPair.set(key, bucket);
(isPayer ? debit : credit).push(bucket);
}
if (!byPair.size) {
regionEmpty(container, "暂无已确认明细", "该公司的确认往来将在科目确认后出现");
return;
}
const section = (title, total, rows) => `
<section><header><h3>${title}</h3><strong>${fmtMoney(total)}</strong></header>
${rows.map((row) => `
<button type="button" class="subject-row" data-open-pair="${row.counterpartyId}" aria-label="查看与 ${esc(row.counterpartyName)} 的往来明细">
<span><b>${esc(row.counterpartyName)}</b><small>${esc(b44.subjectLabels[row.subject] || row.subject)} · ${row.count} 笔</small></span>
<strong>${fmtMoney(row.amount)}</strong><svg><use href="icons.svg#chevron-right"/></svg>
</button>`).join("")}
</section>`;
const debitRows = [...new Map(debit.map((row) => [row.counterpartyId, row])).values()];
const creditRows = [...new Map(credit.map((row) => [row.counterpartyId, row])).values()];
container.innerHTML = section("借方明细", debit.reduce((sum, row) => sum + row.amount, 0), debitRows)
+ section("贷方明细", credit.reduce((sum, row) => sum + row.amount, 0), creditRows);
container.querySelectorAll("[data-open-pair]").forEach((button) => {
button.addEventListener("click", () => {
const counterpartyId = Number(button.dataset.openPair);
drawer.open(button);
renderAdminPair(item.company_id, counterpartyId, item.company_name);
});
});
} catch (error) {
regionError(container, error.message, close);
}
}
// --- Admin: pair detail + events -------------------------------------------
async function renderAdminPair(companyId, counterpartyId, companyName, extraCrumbs = []) {
const otherName = b44.companyNames.get(counterpartyId) || "对方公司";
drawer.renderLayer(
`${companyName || "公司"}${otherName}`,
[{ label: "往来余额", action: () => drawer.close() }, { label: `${companyName || companyId}${otherName}` }],
regionSkeleton(4),
);
try {
const data = await apiJson(`/api/admin/intercompany/pairs/${companyId}/${counterpartyId}?${balanceQuery()}`);
renderAdminPairBody(data, companyId, counterpartyId, extraCrumbs);
} catch (error) {
$("#drawerBody").innerHTML = `<div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>余额计算暂时不可用</strong><p>${esc(error.message)}</p><button type="button" class="button secondary" data-retry>重试</button></div>`;
$("#drawerBody [data-retry]")?.addEventListener("click", () => renderAdminPair(companyId, counterpartyId, companyName, extraCrumbs));
}
}
function renderAdminPairBody(data, companyId, counterpartyId) {
const item = data.items[0];
const a = data.companies.a;
const b = data.companies.b;
const aResult = item.a.result;
const direction = aResult.direction === "receivable" ? "应收" : aResult.direction === "payable" ? "应付" : "持平";
const directionClass = aResult.direction === "receivable" ? "success" : aResult.direction === "payable" ? "danger" : "neutral";
const unresolved = unresolvedText(item.unresolved);
const subjectButtons = Object.values(item.subjects || {}).map((subject) => `
<button data-subject="${subject.subject_code}" aria-pressed="false">
<span>${esc(subject.label)}</span><strong>${fmtMoney(Number(subject.a_debit) - Number(subject.a_credit))} · ${subject.count} 笔</strong>
</button>`).join("");
const body = `
<div class="pair-balance-line is-six">
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong><small class="pair-open-note">B-45 前无可靠期初</small></div>
<div><span>本期借方</span><strong>${fmtMoney(item.a.period.debit)}</strong></div>
<div><span>本期贷方</span><strong>${fmtMoney(item.a.period.credit)}</strong></div>
<div class="pair-final"><span>期末结果</span><strong style="color:var(--color-primary)"><em class="status ${directionClass}" style="margin-right:6px">${direction}</em>${fmtMoney(aResult.signed_amount)}</strong><small class="pair-open-note">${esc(item.a.result.label)}</small></div>
<div class="pair-unresolved ${unresolved.active ? "" : "is-empty"}"><span>未决金额</span><strong>${fmtMoney(item.unresolved.gross_amount)}</strong><small class="pair-open-note">${item.unresolved.count} 笔</small></div>
<div><span>截止日</span><strong class="amount-neutral">${fmtDate(data.window.cutoff)}</strong><small class="pair-cutoff">双方守恒已校验</small></div>
</div>
<div class="subject-strip">
<button class="is-active" data-subject="all" aria-pressed="true"><span>全部往来</span><strong>${item.trace.event_count} 笔</strong></button>
${subjectButtons}
</div>
<div class="table-scroll"><table class="data-table event-table" id="pairEventsTable">
<thead><tr><th>交易日期</th><th>方向</th><th>科目</th><th>本方账户</th><th>对方账户</th><th>摘要</th><th>匹配状态</th><th class="number">金额</th></tr></thead>
<tbody><tr><td colspan="8"><div class="skeleton-row skeleton-block"><span></span><span></span><span></span><span></span><span></span></div></td></tr></tbody>
</table></div>`;
drawer.renderLayer(
`${a.name}${b.name}`,
[
{ label: "往来余额", action: () => drawer.close() },
{ label: `${a.name}${b.name}` },
],
body,
(root) => {
loadPairEvents(root, companyId, counterpartyId, item.trace.events_url);
$$("[data-subject]", root).forEach((button) => button.addEventListener("click", () => {
$$("[data-subject]", root).forEach((item) => {
const active = item === button;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", String(active));
});
const subject = button.dataset.subject;
$$("#pairEventsTable tbody tr[data-subject-row]").forEach((row) => {
row.hidden = subject !== "all" && row.dataset.subjectRow !== subject;
});
}));
},
);
}
async function loadPairEvents(root, companyId, counterpartyId, url) {
const tbody = $("#pairEventsTable tbody", root);
if (!tbody) return;
tbody.setAttribute("aria-busy", "true");
const pageSize = 50;
try {
const data = await apiJson(`${url}&limit=${pageSize}`);
tbody.setAttribute("aria-busy", "false");
if (!data.items.length) {
tbody.innerHTML = '<tr><td colspan="8"><div class="state-panel"><svg class="state-icon"><use href="icons.svg#inbox"/></svg><strong>该区间无往来事件</strong><p>当前公司对在统计区间内没有逐笔事件</p></div></td></tr>';
return;
}
tbody.innerHTML = data.items.map((event) => {
const subject = subjectOf(event, companyId);
const direction = event.payer_company_id === companyId ? "outgoing" : "incoming";
const isReversalOrAdjustment = event.posting_kind !== "normal";
const sign = event.payer_company_id === companyId ? 1 : -1;
return `
<tr class="event-row" data-subject-row="${subject || ""}" tabindex="0" role="button"
aria-label="查看 ${fmtDate(event.effective_at)}${esc(event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name)} 的源行证据"
data-event-id="${event.ledger_event_id}">
<td>${fmtDate(event.effective_at)}${isReversalOrAdjustment ? `<small>${postingLabel(event)}</small>` : ""}</td>
<td>${directionChip(direction)}</td>
<td>${event.state === "confirmed" ? esc(b44.subjectLabels[subject] || "") : '<span class="status info">待确认科目</span>'}</td>
<td>${esc(event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name)}</td>
<td>${esc(event.payer_company_id === companyId ? event.payer_company_name : event.payee_company_name)}</td>
<td>${esc(event.source_kind === "manual" ? "手工记录" : "银行往来")}</td>
<td>${eventStateChip(event.state)}</td>
<td class="number">${sign < 0 ? "" : ""}${fmtMoney(event.amount)}</td>
</tr>`;
}).join("");
$$("#pairEventsTable tbody tr.event-row", root).forEach((row) => {
const open = () => {
drawer.open(row);
renderEventEvidence(Number(row.dataset.eventId), companyId, row);
};
row.addEventListener("click", open);
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
});
if (data.has_more) {
const footer = document.createElement("tr");
footer.innerHTML = '<td colspan="8"><div class="state-panel" style="min-height:80px"><span style="font-size:12px">超过 50 笔,请在服务端按截止日分段查看</span></div></td>';
tbody.append(footer);
}
} catch (error) {
tbody.setAttribute("aria-busy", "false");
tbody.innerHTML = `<tr><td colspan="8"><div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>逐笔事件加载失败</strong><p>${esc(error.message)}</p></div></td></tr>`;
}
}
// --- Evidence drawer ---------------------------------------------------------
async function renderEventEvidence(eventId, companyId, trigger) {
const base = portal === "company" ? "/api/company/intercompany" : "/api/admin/intercompany";
drawer.renderLayer("事件证据", [{ label: "往来余额", action: () => drawer.close() }], regionSkeleton(5));
try {
const [detail, evidence] = await Promise.all([
apiJson(`${base}/events/${eventId}`),
apiJson(`${base}/events/${eventId}/evidence`),
]);
const event = detail.event;
const direction = companyId ? (event.payer_company_id === companyId ? "outgoing" : "incoming") : null;
const subjectLabel = companyId && event.subject_code ? (b44.subjectLabels[subjectOf(event, companyId)] || "") : (b44.subjectLabels[event.subject_code] || "");
const blocks = evidence.blocks.map(renderEvidenceBlock).join('<div class="evidence-midline">双边匹配 · 已去重为同一事件</div>');
const pairCrumb = companyId
? { label: `${event.payer_company_name}${event.payee_company_name}`, action: () => { if (portal !== "company") renderAdminPair(event.payer_company_id, event.payee_company_id, event.payer_company_name); } }
: { label: `${event.payer_company_name}${event.payee_company_name}` };
drawer.renderLayer(
`${fmtDate(event.effective_at)} 事件`,
[
{ label: "往来余额", action: () => drawer.close() },
pairCrumb,
{ label: fmtDate(event.effective_at) },
],
`
<div class="pair-balance-line" style="grid-template-columns:repeat(3,1fr)">
<div><span>方向</span><strong>${direction ? directionChip(direction) : ""}${esc(subjectLabel)}</strong></div>
<div><span>金额</span><strong>${fmtMoney(event.amount)} <small>${esc(event.currency)}</small></strong></div>
<div><span>匹配状态</span><strong>${eventStateChip(detail.state)}</strong></div>
</div>
<div class="evidence-stack">${blocks}</div>`,
);
} catch (error) {
$("#drawerBody").innerHTML = `<div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>证据加载失败</strong><p>${esc(error.message)}</p><button type="button" class="button secondary" data-retry>重试</button></div>`;
$("#drawerBody [data-retry]")?.addEventListener("click", () => renderEventEvidence(eventId, companyId, trigger));
}
}
function renderEvidenceBlock(block) {
if (block.visibility === "missing") {
return `<div class="evidence-block is-missing"><strong>对方源行缺失 · 当前为单边记录</strong><small>该侧没有可展示的银行或手工证据</small></div>`;
}
const sideLabel = block.side === "own" ? "本方" : "对方";
const kindLabel = block.source_kind === "manual" ? "手工记录" : "银行源行";
const title = `${sideLabel} · ${kindLabel}${block.visibility === "masked" ? "(按对方授权不可见)" : ""}`;
if (block.visibility === "masked") {
const fields = block.fields || {};
const rows = [
fields.company_name ? ["对方公司", fields.company_name] : null,
fields.own_account_masked ? ["对方账号", fields.own_account_masked] : null,
fields.state ? ["记录状态", fields.state] : null,
].filter(Boolean).map(([label, value]) => `<div><dt>${esc(label)}</dt><dd class="evidence-masked">${esc(value)}</dd></div>`).join("");
return `<div class="evidence-block is-missing"><strong>${esc(title)}</strong><div class="evidence-grid">${rows || "<div><dt>可见范围</dt><dd class='evidence-masked'>仅对方公司名称与脱敏账号</dd></div>"}</div></div>`;
}
const fields = block.fields || {};
const pairs = [];
if (block.source_kind === "bank") {
pairs.push(
["银行文件", fields.original_filename], ["工作表", fields.sheet_name],
["源行号", fields.source_row], ["交易时间", fields.transaction_at],
["收入", fields.income], ["支出", fields.expense],
["本方账号", fields.own_account], ["本方户名", fields.own_name],
["对方账号", fields.counterparty_account_masked || fields.counterparty_account], ["对方户名", fields.counterparty_name],
["摘要", fields.summary], ["用途", fields.purpose],
["流水号", fields.reference], ["币种", fields.currency],
["观察角色", fields.role],
);
} else {
pairs.push(
["记录编号", fields.manual_record_id], ["提交公司", fields.company_name],
["对方公司", fields.counterparty_company_name], ["业务日期", fields.occurred_at],
["方向", fields.direction === "outgoing" ? "转出" : "转入"], ["金额", `${fields.amount} ${fields.currency}`],
["资金来源", fields.funding_source], ["建议科目", b44.subjectLabels[fields.requested_subject] || fields.requested_subject],
["摘要", fields.summary], ["补充说明", fields.reason],
["状态", fields.state],
);
}
const grid = pairs.filter(([, value]) => value !== null && value !== undefined && value !== "")
.map(([label, value]) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`).join("");
return `<div class="evidence-block"><strong>${esc(title)}</strong><div class="evidence-grid">${grid}</div></div>`;
}
// --- Admin: subject-review and manual-record audit rows ----------------------
function appendSubjectReviewRow(item) {
const tbody = $("#auditRows");
if (!tbody) return;
const row = document.createElement("tr");
row.dataset.auditType = "科目";
row.dataset.recordKind = "subject-review";
row.dataset.ledgerEventId = item.ledger_event_id;
row.dataset.perspectiveId = item.payer_company_id;
row.dataset.expectedRevision = item.revision_id;
row.dataset.company = item.payer_company_name;
const suggestions = (item.suggestions || []).map((s) => `${s.suggested_company_name} · ${s.suggested_subject_label}`).join("") || "无规则建议(需人工判定)";
row.innerHTML = `
<td><span class="task-level warning">中</span></td>
<td><strong>${esc(item.payer_company_name)}${esc(item.payee_company_name)}</strong><small>规则建议:${esc(suggestions)}</small></td>
<td>科目待确认</td>
<td>${fmtDate(item.effective_at)}</td>
<td>${fmtMoney(item.amount)} ${esc(item.currency)}</td>
<td><span class="status info">待确认科目</span></td>
<td><button class="text-button" data-audit-action>确认科目</button></td>`;
tbody.append(row);
}
function appendManualRecordReviewRow(record) {
const tbody = $("#auditRows");
if (!tbody) return;
const row = document.createElement("tr");
row.dataset.auditType = "手工";
row.dataset.recordKind = "manual-review";
row.dataset.recordId = record.id;
row.dataset.expectedDecisionId = record.decision_id;
row.dataset.company = record.company_name;
row.innerHTML = `
<td><span class="task-level warning">中</span></td>
<td><strong>${esc(record.company_name)} · MR-${record.id}</strong><small>${esc(record.direction === "outgoing" ? "付款" : "收款")} ${fmtMoney(record.amount)} ${esc(record.currency)} · ${esc(record.counterparty_company_name)} · ${esc(b44.subjectLabels[record.requested_subject] || "")}</small></td>
<td>手工记录</td>
<td>${fmtDate(record.occurred_at)}</td>
<td>${esc(record.summary || record.reason || "待补充说明")}</td>
<td><span class="status warning">待复核</span></td>
<td><button class="text-button" data-audit-action>复核</button></td>`;
tbody.append(row);
}
async function loadAdminAuditQueue() {
try {
const [subjects, records] = await Promise.all([
apiJson(`/api/admin/subject-reviews?${balanceQuery()}&limit=100`),
apiJson("/api/admin/manual-records?limit=100"),
]);
$$("#auditRows [data-record-kind='subject-review'], #auditRows [data-record-kind='manual-review']").forEach((row) => row.remove());
(subjects.items || []).forEach(appendSubjectReviewRow);
(records.records || []).filter((record) => record.state === "pending").forEach(appendManualRecordReviewRow);
updateAuditCounts();
} catch {
/* the demo audit table stays visible if the queue cannot load */
}
}
// --- Company portal: balances ------------------------------------------------
async function loadCompanyBalances() {
const summaryLine = $("#companyBalanceLine");
const counterpartyList = $("#companyCounterparties");
const alertBox = $("#companyUnresolvedAlert");
if (!summaryLine && !counterpartyList) return;
if (summaryLine) summaryLine.innerHTML = regionSkeleton(1);
if (counterpartyList) counterpartyList.innerHTML = regionSkeleton(2);
try {
const data = await apiJson(`/api/company/intercompany/balances?${balanceQuery()}`);
const totals = { debit: 0, credit: 0, signed: 0, unresolvedGross: 0, unresolvedCount: 0 };
(data.items || []).forEach((item) => {
totals.debit += Number(item.period.debit || 0);
totals.credit += Number(item.period.credit || 0);
totals.signed += Number(item.result.signed_amount || 0);
totals.unresolvedGross += Number(item.unresolved.gross_amount || 0);
totals.unresolvedCount += item.unresolved.count || 0;
});
$("#companyBalancePeriod").textContent = `统计口径 ${fmtDate(data.window.from)}${fmtDate(data.window.cutoff)} · 期初不可用,仅显示期间净变动`;
if (summaryLine) {
const direction = totals.signed > 0 ? "应收" : totals.signed < 0 ? "应付" : "持平";
const directionClass = totals.signed > 0 ? "success" : totals.signed < 0 ? "danger" : "neutral";
summaryLine.innerHTML = `
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong><small class="pair-open-note">B-45 前无可靠期初</small></div>
<div><span>本期借方</span><strong>${fmtMoney(totals.debit)}</strong></div>
<div><span>本期贷方</span><strong>${fmtMoney(totals.credit)}</strong></div>
<div class="pair-final"><span>期末结果</span><strong style="color:var(--color-primary)"><em class="status ${directionClass}" style="margin-right:6px">${direction}</em>${fmtMoney(totals.signed)}</strong><small class="pair-open-note">期间净变动</small></div>
<div class="pair-unresolved ${totals.unresolvedCount ? "" : "is-empty"}"><span>未决金额</span><strong>${fmtMoney(totals.unresolvedGross)}</strong><small class="pair-open-note">${totals.unresolvedCount} 笔</small></div>
<div><span>截止日</span><strong class="amount-neutral">${fmtDate(data.window.cutoff)}</strong><small class="pair-cutoff">按币种独立展示</small></div>`;
}
if (alertBox) {
if (totals.unresolvedCount) {
alertBox.hidden = false;
$("#companyUnresolvedText").textContent = `有 ${totals.unresolvedCount} 笔往来未确认,合计 ${fmtMoney(totals.unresolvedGross)},不影响已确认余额`;
} else {
alertBox.hidden = true;
}
}
renderCompanyCounterparties(counterpartyList, data.counterparties || [], data.window.cutoff);
} catch (error) {
if (summaryLine) regionError(summaryLine, error.message, loadCompanyBalances);
if (counterpartyList) counterpartyList.innerHTML = "";
}
}
function renderCompanyCounterparties(container, counterparties, cutoff) {
if (!container) return;
if (!counterparties.length) {
regionEmpty(container, "暂无对方公司往来", "确认往来事件后,对方公司明细将在此展示");
return;
}
container.replaceChildren(...counterparties.map((row) => {
const article = document.createElement("article");
article.className = "company-row";
article.tabIndex = 0;
article.dataset.counterpartyId = row.counterparty_company_id;
article.setAttribute("role", "button");
article.setAttribute("aria-label", `查看与 ${row.counterparty_company_name} 的往来明细`);
const direction = row.result.direction === "receivable"
? { className: "success", label: "应收" }
: row.result.direction === "payable"
? { className: "danger", label: "应付" }
: { className: "neutral", label: "持平" };
const unresolved = unresolvedText(row.unresolved);
article.innerHTML = `
<span class="company-row-mark">${esc(row.counterparty_company_name.slice(0, 1))}</span>
<div class="company-row-body"><strong>${esc(row.counterparty_company_name)}</strong><small class="currency-tag">${esc(row.currency)} · ${unresolved.active ? `未决 ${fmtMoney(row.unresolved.gross_amount)} · ${row.unresolved.count} 笔` : "未决 0.00"}</small></div>
<div class="company-row-figure"><strong class="result-direction"><em class="status ${direction.className}">${direction.label}</em> ${fmtMoney(row.result.signed_amount)}</strong><small>截止 ${fmtDate(cutoff)}</small></div>`;
const open = () => {
drawer.open(article);
renderCompanyPair(row.counterparty_company_id, row.counterparty_company_name);
};
article.addEventListener("click", open);
article.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
return article;
}));
}
async function renderCompanyPair(counterpartyId, counterpartyName) {
drawer.renderLayer(`${counterpartyName} · 往来明细`, [{ label: "往来余额", action: () => drawer.close() }], regionSkeleton(4));
try {
const data = await apiJson(`/api/company/intercompany/pairs/${counterpartyId}?${balanceQuery()}`);
const item = data.items[0];
const own = data.companies.a;
const aResult = item.a.result;
const direction = aResult.direction === "receivable" ? "应收" : aResult.direction === "payable" ? "应付" : "持平";
const directionClass = aResult.direction === "receivable" ? "success" : aResult.direction === "payable" ? "danger" : "neutral";
const unresolved = unresolvedText(item.unresolved);
const body = `
<div class="pair-balance-line is-six">
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong><small class="pair-open-note">B-45 前无可靠期初</small></div>
<div><span>本期借方</span><strong>${fmtMoney(item.a.period.debit)}</strong></div>
<div><span>本期贷方</span><strong>${fmtMoney(item.a.period.credit)}</strong></div>
<div class="pair-final"><span>期末结果</span><strong style="color:var(--color-primary)"><em class="status ${directionClass}" style="margin-right:6px">${direction}</em>${fmtMoney(aResult.signed_amount)}</strong><small class="pair-open-note">${esc(item.a.result.label)}</small></div>
<div class="pair-unresolved ${unresolved.active ? "" : "is-empty"}"><span>未决金额</span><strong>${fmtMoney(item.unresolved.gross_amount)}</strong><small class="pair-open-note">${item.unresolved.count} 笔</small></div>
<div><span>截止日</span><strong class="amount-neutral">${fmtDate(data.window.cutoff)}</strong><small class="pair-cutoff">双方守恒已校验</small></div>
</div>
<div class="table-scroll"><table class="data-table event-table" id="companyEventsTable">
<thead><tr><th>交易日期</th><th>方向</th><th>科目</th><th>对方公司</th><th>来源</th><th>匹配状态</th><th class="number">金额</th></tr></thead>
<tbody><tr><td colspan="7"><div class="skeleton-row skeleton-block"><span></span><span></span><span></span><span></span><span></span></div></td></tr></tbody>
</table></div>`;
drawer.renderLayer(
`${own.name}${counterpartyName}`,
[{ label: "往来余额", action: () => drawer.close() }, { label: `${own.name}${counterpartyName}` }],
body,
(root) => loadCompanyEvents(root, counterpartyId),
);
} catch (error) {
$("#drawerBody").innerHTML = `<div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>余额计算暂时不可用</strong><p>${esc(error.message)}</p><button type="button" class="button secondary" data-retry>重试</button></div>`;
$("#drawerBody [data-retry]")?.addEventListener("click", () => renderCompanyPair(counterpartyId, counterpartyName));
}
}
async function loadCompanyEvents(root, counterpartyId) {
const tbody = $("#companyEventsTable tbody", root);
if (!tbody) return;
try {
const data = await apiJson(`/api/company/intercompany/events?${balanceQuery()}&limit=100`);
const ownCompanyId = state.me?.company_id;
const filtered = (data.items || []).filter((event) => event.counterparty_company_id === counterpartyId);
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="7"><div class="state-panel"><svg class="state-icon"><use href="icons.svg#inbox"/></svg><strong>该区间无往来事件</strong><p>当前统计区间内没有与对方公司的逐笔事件</p></div></td></tr>';
return;
}
tbody.innerHTML = filtered.map((event) => `
<tr class="event-row" tabindex="0" role="button" data-event-id="${event.ledger_event_id}"
aria-label="查看 ${fmtDate(event.effective_at)}${esc(event.counterparty_company_name)} 的源行证据">
<td>${fmtDate(event.effective_at)}</td>
<td>${directionChip(event.direction)}</td>
<td>${event.state === "confirmed" ? esc(event.own_subject_label || "") : '<span class="status info">待确认科目</span>'}</td>
<td>${esc(event.counterparty_company_name)}</td>
<td>${esc(event.source_kind === "manual" ? "手工记录" : "银行往来")}</td>
<td>${eventStateChip(event.state)}</td>
<td class="number">${event.direction === "outgoing" ? "" : ""}${fmtMoney(event.amount)}</td>
</tr>`).join("");
$$("#companyEventsTable tbody tr.event-row", root).forEach((row) => {
const open = () => {
drawer.open(row);
renderEventEvidence(Number(row.dataset.eventId), ownCompanyId, row);
};
row.addEventListener("click", open);
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
});
} catch (error) {
tbody.innerHTML = `<tr><td colspan="7"><div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>逐笔事件加载失败</strong><p>${esc(error.message)}</p></div></td></tr>`;
}
}
// --- B-44 wiring -------------------------------------------------------------
function initBalanceQueries() {
$$("[data-balance-form]").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
const from = $('[name="from"]', form).value;
const cutoff = $('[name="cutoff"]', form).value;
const currency = $('[name="currency"]', form).value || "";
if (from > cutoff) {
showToast("日期范围无效", "统计起始不能晚于统计截止");
return;
}
b44.from = from;
b44.cutoff = cutoff;
b44.currency = currency;
loadAdminBalances($("#companyLedgers"));
showToast("查询结果已更新", `${from}${cutoff} · 截止日口径`);
});
});
// Dashboard quick-pair form: resolve company names to ids and open the pair.
$$("[data-pair-form]").forEach((form) => form.addEventListener("submit", (event) => {
event.preventDefault();
const from = $('[name="from"]', form).value;
const to = $('[name="to"]', form).value;
if (from === to) {
showToast("请选择两个不同的公司", "同公司账户调拨不进入公司间往来查询");
return;
}
const fromId = [...b44.companyNames.entries()].find(([, name]) => name === from)?.[0];
const toId = [...b44.companyNames.entries()].find(([, name]) => name === to)?.[0];
if (!fromId || !toId) {
showToast("无法解析公司主档", "请先在「公司与账号」中建立公司");
return;
}
renderAdminPair(fromId, toId, from);
}));
// Dashboard quick-pair buttons and static pair links resolve to the drawer.
$$("[data-pair-link]").forEach((button) => button.addEventListener("click", () => {
const [fromName, toName] = String(button.dataset.pairLink || "").split("|");
const fromId = [...b44.companyNames.entries()].find(([, name]) => name === fromName)?.[0];
const toId = [...b44.companyNames.entries()].find(([, name]) => name === toName)?.[0];
if (!fromId || !toId) {
showToast("无法解析演示公司", "正式数据请先在「公司与账号」中建立公司主档");
return;
}
renderAdminPair(fromId, toId, fromName);
}));
}
async function loadCompanyOptions() {
try {
const data = await apiJson("/api/admin/companies");
(data.companies || []).forEach((company) => b44.companyNames.set(company.id, company.name));
} catch {
/* best-effort name resolution for dashboard pair links */
}
}
async function loadCompanyDirectoryOptions() {
try {
const data = await apiJson("/api/company/companies");
(data.companies || []).forEach((company) => b44.companyNames.set(company.id, company.name));
const select = $("#manualEntryForm [name='counterparty']");
if (select) {
const kept = [...select.options].filter((option) => option.dataset.keep);
select.replaceChildren(...kept);
(data.companies || []).forEach((company) => select.add(new Option(company.name, company.id)));
}
} catch {
/* the manual form keeps the free-text counterparty as fallback */
}
}
async function submitCompanyManualRecord(form) {
const data = new FormData(form);
const sourceOption = $('[name="sourceAccount"] option:checked', form);
const counterpartyOption = $('[name="counterparty"] option:checked', form);
const payload = {
counterparty_company_id: Number(counterpartyOption?.value || 0) || null,
occurred_at: `${String(data.get("transactionDate"))}T12:00:00`,
direction: String(data.get("direction")) === "付款" ? "outgoing" : "incoming",
amount: String(data.get("amount")),
currency: "CNY",
funding_source: sourceOption?.dataset.accountId ? "approved_bank_account" : "other",
bank_account_id: sourceOption?.dataset.accountId ? Number(sourceOption.dataset.accountId) : null,
requested_subject: b44.subjectCodes[String(data.get("subject"))] || "other_receivable",
summary: String(data.get("summary") || "").trim(),
reason: String(data.get("remark") || "").trim(),
request_key: `mr-${Date.now().toString(36)}`,
};
if (!payload.counterparty_company_id) {
showToast("请选择对方公司", "手工记录对方必须是集团内部公司");
return;
}
if (!payload.amount || Number(payload.amount) <= 0) {
showToast("金额无效", "金额必须大于零");
return;
}
try {
const result = await apiJson("/api/company/manual-records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
form.reset();
showToast("手工记录已提交", "总账复核前不会纳入公司间往来计算");
await loadCompanyManualRecords();
return result;
} catch (error) {
showToast("手工记录提交失败", error.message);
}
}
async function loadCompanyManualRecords() {
const tbody = $("#manualRecordRows");
if (!tbody) return;
try {
const data = await apiJson("/api/company/manual-records?limit=100");
$$('[data-stored-record]', tbody).forEach((row) => row.remove());
(data.records || []).forEach((record) => appendCompanyManualRow(record));
const pending = (data.records || []).filter((record) => record.state === "pending").length;
const status = $("#manualPendingStatus");
if (status) status.textContent = `${pending} 笔待总账复核`;
} catch {
/* keep the demo rows when the API is unavailable */
}
}
function appendCompanyManualRow(record) {
const tbody = $("#manualRecordRows");
if (!tbody) return;
const row = document.createElement("tr");
row.dataset.storedRecord = record.id;
const identity = document.createElement("td");
const id = document.createElement("strong"); id.textContent = `MR-${record.id}`;
const date = document.createElement("small"); date.textContent = fmtDate(record.occurred_at);
identity.append(id, date);
const direction = document.createElement("td");
const directionName = document.createElement("strong"); directionName.textContent = record.direction === "outgoing" ? "付款" : "收款";
const subject = document.createElement("small"); subject.textContent = b44.subjectLabels[record.requested_subject] || record.requested_subject;
direction.append(directionName, subject);
const source = document.createElement("td"); source.textContent = { approved_bank_account: "已批准账户", personal_transit: "个人过账", other: "其他来源" }[record.funding_source] || record.funding_source;
const counterparty = document.createElement("td");
const counterpartyName = document.createElement("strong"); counterpartyName.textContent = record.counterparty_company_name;
counterparty.append(counterpartyName);
const summary = document.createElement("td"); summary.textContent = record.summary || record.reason || "—";
const amount = document.createElement("td"); amount.className = "number"; amount.textContent = fmtMoney(record.amount);
const statusCell = document.createElement("td");
const stateMap = { pending: ["warning", "待总账复核"], approved: ["success", "已确认"], returned: ["danger", "已退回"], exception: ["neutral", "异常待处理"], reversed: ["neutral", "已冲销"] };
const [className, label] = stateMap[record.state] || ["neutral", record.state];
const badge = document.createElement("span"); badge.className = `status ${className}`; badge.textContent = label;
statusCell.append(badge);
row.append(identity, direction, source, counterparty, summary, amount, statusCell);
tbody.append(row);
}
function renderCompanyManualRecords() {
const tbody = $("#manualRecordRows");
if (!tbody) return;
$$('[data-stored-record]', tbody).forEach((row) => row.remove());
const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司");
[...records].reverse().forEach((record) => {
const row = document.createElement("tr");
row.dataset.storedRecord = record.id;
const identity = document.createElement("td");
const id = document.createElement("strong"); id.textContent = record.id;
const date = document.createElement("small"); date.textContent = record.transactionDate;
identity.append(id, date);
const direction = document.createElement("td");
const directionName = document.createElement("strong"); directionName.textContent = record.direction;
const subject = document.createElement("small"); subject.textContent = record.subject;
direction.append(directionName, subject);
const source = document.createElement("td"); source.textContent = record.sourceAccount;
const counterparty = document.createElement("td");
const counterpartyName = document.createElement("strong"); counterpartyName.textContent = record.counterparty;
const counterpartyType = document.createElement("small"); counterpartyType.textContent = record.counterpartyType;
counterparty.append(counterpartyName, counterpartyType);
const summary = document.createElement("td"); summary.textContent = record.summary;
const amount = document.createElement("td"); amount.className = "number"; amount.textContent = formatCurrency(record.amount);
const statusCell = document.createElement("td");
const status = recordStatus(record.status);
const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label;
statusCell.append(badge);
row.append(identity, direction, source, counterparty, summary, amount, statusCell);
tbody.append(row);
});
const pending = records.filter((record) => record.status === "待总账复核").length + 1;
if ($("#manualPendingStatus")) $("#manualPendingStatus").textContent = `${pending} 笔待总账复核`;
}
function fillAccountSelects(accounts) {
const usable = accounts.filter((account) => account.usable);
[$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => {
if (!select) return;
const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账");
select.replaceChildren(...kept);
usable.forEach((account) => {
const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`;
const option = new Option(value, value);
option.dataset.accountId = account.id;
select.add(option);
});
});
}
function renderCompanyAccounts(accounts) {
const directory = $("#accountDirectory");
if (directory) {
directory.replaceChildren(...accounts.map((account) => {
const article = document.createElement("article");
const header = document.createElement("header");
const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = account.bank_name.slice(0, 1);
const identity = document.createElement("div");
const name = document.createElement("strong"); name.textContent = account.bank_name;
const meta = document.createElement("small"); meta.textContent = `${account.account_type} · 尾号 ${accountTail(account.account_number_masked)}`;
identity.append(name, meta);
const status = recordStatus(accountStatusLabel(account.status));
const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label;
header.append(mark, identity, badge);
const details = document.createElement("dl");
const rows = [
["申请启用", account.effective_from || "待审核确定"],
["流水覆盖", account.usable ? "尚未上传" : "不参与计算"],
["提交时间", String(account.created_at || "").slice(0, 10) || "—"],
];
if (account.status === "returned" && account.review_reason) rows.push(["退回原因", account.review_reason]);
if (account.status === "disabled" && account.effective_to) rows.push(["停用日期", account.effective_to]);
rows.forEach(([term, value]) => {
const wrapper = document.createElement("div");
const dt = document.createElement("dt"); dt.textContent = term;
const dd = document.createElement("dd"); dd.textContent = value;
wrapper.append(dt, dd); details.append(wrapper);
});
article.append(header, details);
return article;
}));
}
fillAccountSelects(accounts);
}
async function loadCompanyAccounts() {
const response = await fetch("/api/company/accounts").catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
if (!response?.ok) return;
const result = await response.json().catch(() => null);
if (result?.accounts) renderCompanyAccounts(result.accounts);
}
function appendAdminReviewRow(record, kind) {
const tbody = $("#auditRows");
if (!tbody) return;
const isAccount = kind === "account";
// Account rows come from the server (full number visible only in this
// authorized admin view); manual records are still browser-local demo data.
const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status;
const row = document.createElement("tr");
row.dataset.storedReview = record.id;
row.dataset.recordId = record.id;
row.dataset.recordKind = kind;
if (isAccount) row.dataset.accountId = record.id;
row.dataset.auditType = isAccount ? "账户" : "手工";
row.dataset.company = isAccount ? record.company_name : record.company;
row.dataset.evidence = isAccount
? "公司提交资料、开户行、账号、账户类型与启用日期"
: "公司手工记录、关联银行流水号、证明附件与提交说明";
if (statusLabel !== "待复核" && statusLabel !== "待总账复核") row.dataset.resolved = "true";
const riskCell = document.createElement("td");
const risk = document.createElement("span"); risk.className = "task-level warning"; risk.textContent = "中"; riskCell.append(risk);
const identityCell = document.createElement("td");
const identity = document.createElement("strong");
const detail = document.createElement("small");
const typeCell = document.createElement("td");
const periodCell = document.createElement("td");
const impactCell = document.createElement("td");
if (isAccount) {
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
typeCell.textContent = "账户登记";
periodCell.textContent = record.effective_from || "待审核确定";
impactCell.textContent = "账户识别与流水上传";
} else {
identity.textContent = `${record.company} · ${record.id}`;
detail.textContent = `${record.direction} ${formatCurrency(record.amount)} 元 · ${record.counterparty} · ${record.subject}`;
typeCell.textContent = "手工记录";
periodCell.textContent = record.transactionDate;
impactCell.textContent = `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`;
}
identityCell.append(identity, detail);
const statusCell = document.createElement("td");
const status = recordStatus(statusLabel);
const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label; statusCell.append(badge);
const actionCell = document.createElement("td");
const action = document.createElement("button"); action.className = "text-button"; action.dataset.auditAction = "";
if (isAccount) row.dataset.accountStatus = record.status;
action.textContent = row.dataset.resolved
? (isAccount && record.status === "active" ? "管理" : "查看记录")
: "复核";
if (row.dataset.resolved) {
const decisionLabels = { active: "复核通过并启用账户", returned: "退回公司修改", disabled: "停用并驳回" };
const decisionLabel = isAccount ? decisionLabels[record.status] || statusLabel : record.decision || record.status;
const reasonText = (isAccount ? record.review_reason : record.reviewReason) || "已留痕";
action.dataset.record = `${decisionLabel} · ${reasonText}`;
action.dataset.decision = decisionLabel;
action.dataset.reason = reasonText;
action.dataset.processedAt = (isAccount ? record.reviewed_at : record.reviewedAt) || "时间未记录";
}
actionCell.append(action);
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
tbody.append(row);
}
async function renderAdminAccountReviews() {
const tbody = $("#auditRows");
if (!tbody) return;
$$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove());
const response = await fetch("/api/admin/accounts").catch(() => null);
if (!response?.ok) return;
const result = await response.json().catch(() => null);
(result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
updateAuditCounts();
}
function renderStoredAdminReviews() {
if (!$("#auditRows")) return;
$$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove());
readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual"));
renderAdminAccountReviews();
}
function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) {
if (kind !== "manual" || !id) return;
const records = readStoredRecords(storageKeys.manual);
const record = records.find((item) => item.id === id);
if (!record) return;
Object.assign(record, { status, decision, reviewReason, reviewedAt });
writeStoredRecords(storageKeys.manual, records);
}
function updateAuditCounts() {
const rows = $$("#auditRows tr");
const unresolved = rows.filter((row) => row.dataset.resolved !== "true");
$$('[data-audit-filter]').forEach((button) => {
const type = button.dataset.auditFilter;
const count = unresolved.filter((row) => type === "all" || row.dataset.auditType === type).length;
button.textContent = `${button.dataset.label} ${count}`;
});
const badge = $('.nav-item[data-view="audit"] b');
if (badge) badge.textContent = unresolved.length;
}
function companyStatusBadge(status) {
if (status === "preparing") return { className: "neutral", label: "筹备中" };
if (status === "disabled") return { className: "danger", label: "已停用" };
return { className: "success", label: "正常" };
}
function renderAdminCompanyTable(companies) {
const tbody = $("#companyTable tbody");
if (!tbody) return;
tbody.replaceChildren(...companies.map((company) => {
const row = document.createElement("tr");
const nameCell = document.createElement("td");
const name = document.createElement("strong"); name.textContent = company.name;
const code = document.createElement("small"); code.textContent = `COMP-${String(company.id).padStart(3, "0")}`;
nameCell.append(name, code);
const credit = document.createElement("td"); credit.textContent = company.credit_code || "待补充";
const accounts = document.createElement("td"); accounts.textContent = `${company.account_count ?? 0} 个`;
const usernames = document.createElement("td"); usernames.textContent = company.usernames || "未创建";
const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定";
const statusCell = document.createElement("td");
const badge = companyStatusBadge(company.status);
statusCell.innerHTML = `<span class="status ${badge.className}">${badge.label}</span>`;
const actionCell = document.createElement("td");
actionCell.innerHTML = `<button class="text-button" data-toast="已打开 ${company.name} 主档">管理</button>`;
row.append(nameCell, credit, accounts, usernames, cashier, statusCell, actionCell);
return row;
}));
}
function setSelectOptions(select, names, { keepFirst = false } = {}) {
if (!select || !names.length) return;
const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : [];
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
}
function fillCompanySelects(names) {
// Every company picker is driven by master data: a newly created company
// appears in pair queries, audit filters, flow filters and reminders
// without any code change.
$$("[data-pair-form]").forEach((form) => {
setSelectOptions($('[name="from"]', form), names);
setSelectOptions($('[name="to"]', form), names);
const toSelect = $('[name="to"]', form);
if (toSelect && names.length > 1) toSelect.value = names[1];
});
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
setSelectOptions($('#reminderForm [name="company"]'), names, { keepFirst: true });
setSelectOptions($('#openingDialog [name="from"]'), names);
setSelectOptions($('#openingDialog [name="to"]'), names);
}
async function loadAdminCompanies() {
const response = await fetch("/api/admin/companies").catch(() => null);
if (!response?.ok) return;
const result = await response.json().catch(() => null);
const companies = result?.companies || [];
renderAdminCompanyTable(companies);
fillCompanySelects(companies.map((company) => company.name));
}
function initAdmin() {
renderStoredAdminReviews();
updateAuditCounts();
loadAdminCompanies();
initBalanceQueries();
loadCompanyOptions().then(() => {
loadAdminBalances($("#companyLedgers"));
});
loadAdminAuditQueue();
const companySearch = $('[data-filter-target="companyLedgers"]');
companySearch?.addEventListener("input", () => {
const query = companySearch.value.trim().toLowerCase();
$$("#companyLedgers .company-ledger").forEach((item) => { item.hidden = query ? !item.textContent.toLowerCase().includes(query) : false; });
});
let activeAuditType = "all";
function filterAuditRows() {
const company = $("#auditCompany")?.value || "全部公司";
$$(".audit-table tbody tr").forEach((row) => {
const typeMatches = activeAuditType === "all" || row.dataset.auditType === activeAuditType;
const companyMatches = company === "全部公司" || row.dataset.company === company;
row.hidden = !(typeMatches && companyMatches);
});
}
$$("[data-audit-filter]").forEach((button) => button.addEventListener("click", () => {
activeAuditType = button.dataset.auditFilter;
$$("[data-audit-filter]").forEach((item) => {
const active = item === button;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", String(active));
});
filterAuditRows();
}));
$("#auditCompany")?.addEventListener("change", filterAuditRows);
const auditDialog = $("#auditDialog");
$$('[data-close-audit]').forEach((button) => button.addEventListener("click", () => auditDialog.close()));
// Delegated: account review rows arrive asynchronously from the API.
$("#auditRows")?.addEventListener("click", (event) => {
const button = event.target.closest("[data-audit-action]");
if (!button) return;
const row = button.closest("tr");
state.auditRow = row;
const cells = $$('td', row);
const form = $("#auditForm");
const decision = $('[name="decision"]', form);
const reason = $('[name="reason"]', form);
const submit = $('button[type="submit"]', form);
form.reset();
const isActiveAccount = row.dataset.recordKind === "account" && row.dataset.accountStatus === "active";
const decisions = row.dataset.recordKind === "account"
? (isActiveAccount ? ["停用并驳回"] : ["复核通过并启用账户", "退回公司修改", "停用并驳回"])
: row.dataset.recordKind === "subject-review"
? ["应收", "应付", "其他应收", "其他应付"]
: row.dataset.recordKind === "manual-review"
? ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"]
: ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"];
decision.replaceChildren(new Option("请选择", ""), ...decisions.map((item) => new Option(item, item)));
decision.disabled = false;
reason.disabled = false;
submit.hidden = false;
$("#auditDialogTitle").textContent = `${cells[2].innerText.trim()} · ${cells[1].querySelector("strong").textContent}`;
$("#auditDialogMeta").textContent = `${cells[3].innerText.trim()} · 影响 ${cells[4].innerText.trim()}`;
const evidence = $("#auditEvidence");
evidence.replaceChildren();
const heading = document.createElement("strong"); heading.textContent = cells[1].querySelector("strong").textContent;
const detail = document.createElement("small"); detail.textContent = cells[1].querySelector("small").textContent;
const source = document.createElement("small"); source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`;
evidence.append(heading, detail, source);
if (button.dataset.record && !isActiveAccount) {
decision.value = button.dataset.decision;
reason.value = button.dataset.reason;
decision.disabled = true;
reason.disabled = true;
submit.hidden = true;
$("#auditDialogMeta").textContent = `已处理 · ${button.dataset.processedAt} · 系统管理员`;
const record = document.createElement("small");
record.textContent = `处理记录:${button.dataset.record}`;
evidence.append(record);
}
auditDialog.showModal();
});
$("#auditForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const row = state.auditRow;
const decision = String(data.get("decision"));
const reason = String(data.get("reason"));
// B-44 subject review: confirm the statutory subject on a pending event.
if (row.dataset.recordKind === "subject-review") {
const subjectCode = b44.subjectCodes[decision] || Object.keys(b44.subjectLabels).find((key) => b44.subjectLabels[key] === decision);
if (!subjectCode) {
showToast("请选择确认科目", "科目必须是应收、应付、其他应收或其他应付之一");
return;
}
const response = await apiJson(`/api/admin/intercompany/events/${row.dataset.ledgerEventId}/subject-decisions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
perspective_company_id: Number(row.dataset.perspectiveId),
subject_code: subjectCode,
reason,
expected_revision: Number(row.dataset.expectedRevision),
request_key: `subj-${row.dataset.ledgerEventId}-${Date.now().toString(36)}`,
}),
}).catch(() => null);
if (!response) {
showToast("科目确认失败", "请刷新后重试");
return;
}
row.remove();
updateAuditCounts();
auditDialog.close();
event.currentTarget.reset();
showToast("科目已确认并纳入计算", `确认科目:${b44.subjectLabels[subjectCode]} · 已写入修订链`);
await loadAdminAuditQueue();
return;
}
// B-44 manual record review: approve / return / exception.
if (row.dataset.recordKind === "manual-review") {
const actionMap = {
"确认并纳入计算": "approve_new",
"退回公司补充材料": "return",
"转为异常待后续处理": "exception",
};
const action = actionMap[decision];
if (!action) {
showToast("请选择处理决定", "人工记录仅支持确认、退回或转异常");
return;
}
const response = await apiJson(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action,
reason,
expected_decision_id: Number(row.dataset.expectedDecisionId),
request_key: `mr-dec-${row.dataset.recordId}-${Date.now().toString(36)}`,
}),
}).catch(() => null);
if (!response) {
showToast("审核结果提交失败", "请刷新后重试");
return;
}
const status = $("[data-audit-action]", row);
if (status) status.textContent = "查看记录";
row.dataset.resolved = "true";
const statusCell = row.children[5];
statusCell.innerHTML = `<span class="status ${action === "approve_new" ? "success" : "neutral"}">${action === "approve_new" ? "已确认" : action === "return" ? "已退回" : "异常待处理"}</span>`;
updateAuditCounts();
auditDialog.close();
event.currentTarget.reset();
showToast("审核结果已记录", action === "approve_new" ? "已批准的手工记录将幂等纳入往来计算" : "当前记录不参与往来计算");
await loadAdminAuditQueue();
return;
}
const approved = decision.includes("通过") || decision.includes("确认并纳入");
const returned = decision.includes("退回");
let storedStatus;
let reviewedAccount = null;
if (row.dataset.recordKind === "account" && row.dataset.accountId) {
// Server-side review: the account only becomes usable after this succeeds.
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: apiDecision, reason }),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("审核结果提交失败", result?.message || "请稍后重试");
return;
}
reviewedAccount = result.account;
storedStatus = accountStatusLabel(result.account?.status);
} else {
storedStatus = approved
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
: (returned ? "已退回" : "异常待处理");
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, reason, new Date().toLocaleString("zh-CN", { hour12: false }));
}
const status = recordStatus(storedStatus);
const statusCell = row.children[5];
statusCell.innerHTML = `<span class="status ${status.className}">${status.label}</span>`;
row.dataset.resolved = "true";
const action = $("[data-audit-action]", row);
if (reviewedAccount) {
row.dataset.accountStatus = reviewedAccount.status;
action.textContent = reviewedAccount.status === "active" ? "管理" : "查看记录";
} else {
action.textContent = "查看记录";
}
action.dataset.record = `${decision} · ${data.get("reason")}`;
action.dataset.decision = decision;
action.dataset.reason = data.get("reason");
action.dataset.processedAt = new Date().toLocaleString("zh-CN", { hour12: false });
updateAuditCounts();
auditDialog.close();
event.currentTarget.reset();
showToast("审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算");
});
const dialog = $("#companyDialog");
$("#openCompanyDialog")?.addEventListener("click", () => dialog.showModal());
$("#companyForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const companyName = String(data.get("companyName") || "").trim();
const loginName = String(data.get("loginName") || "").trim();
const createUser = data.get("createUser") !== null;
const payload = {
name: companyName,
credit_code: String(data.get("creditCode") || "").trim(),
cashier_name: String(data.get("cashier") || "").trim(),
};
if (createUser && loginName) payload.username = loginName;
const response = await fetch("/api/admin/companies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("公司创建失败", result?.message || "请稍后重试");
return;
}
const accountCreated = Boolean(result.username);
dialog.close();
event.currentTarget.reset();
await loadAdminCompanies();
showToast(
accountCreated ? "公司与账号已创建" : "公司已创建",
accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
);
});
$("#systemSettings")?.addEventListener("submit", (event) => {
event.preventDefault();
showToast("系统计算口径已保存", "正式系统将记录修改前后值与操作人");
});
$("#runClosingCheck")?.addEventListener("click", () => {
const unresolved = $$(".audit-table tbody tr").filter((row) => row.dataset.resolved !== "true" && !$(".status.success", row)).length;
if (unresolved) {
showToast("结账检查未通过", `仍有 ${unresolved} 项审核事项,已打开审核中心`);
showView("audit");
} else {
$$("#closingPanel .is-blocked").forEach((item) => {
item.classList.remove("is-blocked");
$("use", item).setAttribute("href", "icons.svg#circle-check");
$("small", item).textContent = "检查已通过";
});
$("#closingDescription").textContent = "2026 年 7 月 · 全部前置检查已通过";
$("#closingStatus").className = "status success";
$("#closingStatus").textContent = "可结账";
$("#executeClosing").disabled = false;
$("#executeClosing").removeAttribute("title");
showToast("结账检查通过", "执行结账按钮已解锁");
}
});
const closingDialog = $("#closingDialog");
$("#executeClosing")?.addEventListener("click", () => closingDialog.showModal());
$$('[data-close-closing]').forEach((button) => button.addEventListener("click", () => closingDialog.close()));
$("#closingForm")?.addEventListener("submit", (event) => {
event.preventDefault();
closingDialog.close();
$("#closingDescription").textContent = "2026 年 7 月 · 已完成集团结账";
$("#closingStatus").className = "status success";
$("#closingStatus").textContent = "已结账";
$("#closingHistory").textContent = `${new Date().toLocaleString("zh-CN", { hour12: false })} · 系统管理员执行 2026 年 7 月结账 · 已写入审计记录`;
$("#runClosingCheck").disabled = true;
$("#executeClosing").disabled = true;
$("#executeClosing").textContent = "7 月已结账";
showToast("2026 年 7 月已完成结账", "本期结果已锁定,后续补录将进入重开流程");
});
const openingDialog = $("#openingDialog");
$("#openOpeningDialog")?.addEventListener("click", () => openingDialog.showModal());
$$('[data-close-opening]').forEach((button) => button.addEventListener("click", () => openingDialog.close()));
$("#openingForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
if (data.get("from") === data.get("to")) {
showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初");
return;
}
const row = document.createElement("tr");
[data.get("from"), data.get("to"), data.get("subject"), data.get("direction")].forEach((value) => {
const cell = document.createElement("td"); cell.textContent = value; row.append(cell);
});
const amount = document.createElement("td"); amount.className = "number"; amount.textContent = Number(data.get("amount")).toLocaleString("zh-CN", {minimumFractionDigits:2}); row.append(amount);
const date = document.createElement("td"); date.textContent = data.get("effectiveDate"); row.append(date);
const status = document.createElement("td"); status.innerHTML = '<span class="status warning">待复核</span>'; row.append(status);
$("#openingRows").append(row);
openingDialog.close();
event.currentTarget.reset();
showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人");
});
$("#reminderForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const article = document.createElement("article");
article.innerHTML = '<span class="notification-icon warning"><svg><use href="icons.svg#bell"/></svg></span>';
const content = document.createElement("span");
const heading = document.createElement("strong");
heading.textContent = `${data.get("company")} · ${data.get("message")}`;
const meta = document.createElement("small");
meta.textContent = `手动提醒 · 截止 ${data.get("dueDate")} · 刚刚`;
content.append(heading, meta);
const status = document.createElement("em");
status.className = "status warning";
status.textContent = "未读";
article.append(content, status);
$("#adminReminderList").prepend(article);
event.currentTarget.reset();
showToast("提醒已发送", "对方将在公司业务端收到站内通知");
});
}
function visibleRows(table) {
return $$('tbody tr', table).filter((row) => !row.hidden);
}
function filterFlows() {
const table = $("#flowTable");
if (!table) return;
const company = $("#flowCompany")?.value || "全部公司";
const bank = $("#flowBank")?.value || "全部银行";
const account = $("#flowAccount")?.value || "全部账户";
const startDate = $("#flowStart")?.value || "0000-01-01";
const endDate = $("#flowEnd")?.value || "9999-12-31";
const keyword = $("#flowKeyword")?.value.trim().toLowerCase() || "";
if (startDate > endDate) {
showToast("日期范围无效", "开始日期不能晚于结束日期");
return;
}
let count = 0;
$$("tbody tr", table).forEach((row) => {
const rowDate = $("td", row).textContent.trim().replaceAll(".", "-");
const matchesCompany = company === "全部公司" || row.dataset.company === company;
const matchesBank = bank === "全部银行" || row.dataset.bank === bank;
const matchesAccount = account === "全部账户" || row.textContent.includes(account);
const matchesDate = rowDate >= startDate && rowDate <= endDate;
const matchesKeyword = !keyword || row.textContent.toLowerCase().includes(keyword);
row.hidden = !(matchesCompany && matchesBank && matchesAccount && matchesDate && matchesKeyword);
if (!row.hidden) count += 1;
});
$("#flowCount").textContent = count;
showToast("查询完成", `当前显示 ${count} 笔流水`);
}
function exportFlows() {
const table = $("#flowTable");
const rows = visibleRows(table);
const headers = ["交易日期", "公司", "银行及账号", "收付方向", "对方户名及账号", "摘要", "银行流水号", "归集状态", "金额", "导入批次", "源行定位"];
const records = rows.map((row, index) => {
const cells = $$('td', row).map((cell) => cell.innerText.replace(/\n/g, " ").trim());
return [cells[0], portal === "company" ? "A公司" : cells[1].split(" ")[0], portal === "company" ? cells[1] : cells[1], ...cells.slice(2), `IMP-DEMO-${String(index + 1).padStart(3, "0")}`, `Sheet1!R${index + 8}`];
});
const csv = [headers, ...records].map((record) => record.map((value) => `"${String(value ?? "").replace(/"/g, '""')}"`).join(",")).join("\r\n");
const link = document.createElement("a");
link.href = URL.createObjectURL(new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" }));
link.download = `${portal === "admin" ? "集团" : "A公司"}银行流水_202607.csv`;
link.click();
URL.revokeObjectURL(link.href);
showToast("导出已生成", `共 ${rows.length} 笔,已保留银行标识与源行定位`);
}
function initFlowTools() {
$("#applyFlowFilters")?.addEventListener("click", filterFlows);
$("#exportFlows")?.addEventListener("click", exportFlows);
}
function resetUpload() {
state.selectedFile = null;
state.parseResult = null;
$("#uploadForm")?.reset();
if ($("#filePreview")) $("#filePreview").hidden = true;
if ($("#parseResult")) $("#parseResult").hidden = true;
if ($("#sheetReview")) $("#sheetReview").hidden = true;
if ($("#sheetList")) $("#sheetList").replaceChildren();
if ($("#dropzone")) $("#dropzone").hidden = false;
if ($("#parseButton")) {
$("#parseButton").disabled = true;
$("#parseButton span").textContent = "开始解析";
delete $("#parseButton").dataset.stage;
}
}
function updateParseButton() {
const button = $("#parseButton");
if (button) button.disabled = !(state.selectedFile && $("#accountSelect").value);
}
function acceptFile(file) {
if (!file) return;
const extension = file.name.split(".").pop().toLowerCase();
if (!["xls", "xlsx"].includes(extension)) {
showToast("文件格式不支持", "请选择银行导出的 .xls 或 .xlsx 文件");
return;
}
state.selectedFile = file;
state.parseResult = null;
$("#fileName").textContent = file.name;
$("#fileMeta").textContent = `${(file.size / 1024).toFixed(1)} KB · 等待表头识别`;
$("#filePreview").hidden = false;
$("#dropzone").hidden = true;
$("#parseResult").hidden = true;
delete $("#parseButton").dataset.stage;
updateParseButton();
}
async function parseFile() {
const formData = new FormData();
formData.append("file", state.selectedFile);
const selectedAccount = $("#accountSelect")?.selectedOptions?.[0];
if (selectedAccount?.dataset.accountId) {
formData.append("bank_account_id", selectedAccount.dataset.accountId);
}
let result;
let parsed = false;
try {
const response = await fetch("/api/parse", { method: "POST", body: formData });
if (response.status === 401 || response.status === 403) {
window.location.href = "index.html";
return;
}
result = await response.json();
parsed = response.ok && ["parsed", "duplicate"].includes(result.status);
} catch {
result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" };
}
state.parseResult = result;
renderParseResult(result, parsed);
}
function renderParseResult(result, parsed) {
const panel = $("#parseResult");
if (!panel) return;
const sheets = Array.isArray(result.sheets) ? result.sheets : [];
const duplicated = result.status === "duplicate";
const opaque = duplicated && !sheets.length;
panel.classList.toggle("is-exception", !parsed);
$("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
$("#parseTitle").textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
const pendingCount = sheets.filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
const exceptionCount = sheets.filter((s) => s.outcome === "exception").length;
const ignoredCount = sheets.filter((s) => s.outcome === "ignored" || s.review_status === "ignored").length;
let summary;
if (opaque) {
summary = "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。";
} else if (sheets.length) {
summary = `${sheets.length} 个工作表:${pendingCount} 个待确认${exceptionCount ? `、${exceptionCount} 个异常` : ""}${ignoredCount ? `、${ignoredCount} 个忽略` : ""}。解析成功不等于业务确认。`;
} else {
summary = `${result.message || ""} 系统不会猜测模板或自动入账。`;
}
$("#parseSummary").textContent = summary;
panel.hidden = false;
renderSheetList(sheets, result.batch_id);
const button = $("#parseButton");
button.dataset.stage = "confirm";
if (!parsed) {
button.querySelector("span").textContent = "关闭";
} else if (opaque) {
button.querySelector("span").textContent = "完成";
} else if (sheets.length) {
button.querySelector("span").textContent = pendingCount ? `确认全部(${pendingCount} 个待确认)` : "完成";
} else {
button.querySelector("span").textContent = "完成";
}
}
function sheetStatusMeta(sheet) {
if (sheet.review_status === "confirmed") return { className: "success", label: "已确认" };
if (sheet.review_status === "ignored") return { className: "neutral", label: "已忽略" };
if (sheet.outcome === "exception") return { className: "danger", label: "异常待处理" };
if (sheet.outcome === "ignored") return { className: "neutral", label: "空表忽略" };
return { className: "warning", label: "待确认" };
}
function renderSheetList(sheets, batchId) {
const wrap = $("#sheetReview");
const list = $("#sheetList");
if (!wrap || !list || !sheets.length) {
if (wrap) wrap.hidden = true;
return;
}
wrap.hidden = false;
list.replaceChildren(...sheets.map((sheet) => buildSheetItem(sheet, batchId)));
}
function buildSheetItem(sheet, batchId) {
const item = document.createElement("article");
item.className = "sheet-item";
if (sheet.review_status === "pending") item.classList.add("is-pending");
const meta = sheetStatusMeta(sheet);
const head = document.createElement("div");
head.className = "sheet-item-head";
const name = document.createElement("strong");
name.textContent = sheet.sheet_name;
const badge = document.createElement("em");
badge.className = `status ${meta.className}`;
badge.textContent = meta.label;
head.append(name, badge);
const details = document.createElement("p");
details.className = "sheet-item-meta";
if (sheet.outcome === "parsed" && sheet.bank) {
const period = sheet.period_start ? ` · ${sheet.period_start}${sheet.period_end}` : "";
details.textContent = `${sheet.bank} · ${sheet.transactions} 条明细${period}`;
} else if (sheet.message) {
details.textContent = sheet.message;
} else {
details.textContent = "空工作表。";
}
if (sheet.review_reason) {
details.textContent += ` · 原因:${sheet.review_reason}`;
}
const body = document.createElement("div");
body.append(head, details);
const actions = document.createElement("div");
actions.className = "sheet-item-actions";
if (sheet.review_status === "pending") {
if (sheet.outcome === "parsed") {
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.className = "text-button";
confirmButton.textContent = "确认";
confirmButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "confirm"));
actions.append(confirmButton);
}
const ignoreButton = document.createElement("button");
ignoreButton.type = "button";
ignoreButton.className = "text-button";
ignoreButton.textContent = "忽略";
ignoreButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "ignore"));
actions.append(ignoreButton);
}
item.append(body, actions);
return item;
}
async function sheetReviewAction(batchId, sheetName, decision) {
const payload = { sheets: [sheetName] };
if (decision === "ignore") {
const reason = (window.prompt("请填写忽略原因(必填):", "") || "").trim();
if (!reason) {
showToast("忽略未提交", "必须填写忽略原因");
return;
}
payload.reason = reason;
}
const response = await fetch(`/api/batches/${batchId}/${decision}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("操作失败", result?.message || "请稍后重试");
return;
}
showToast(decision === "confirm" ? "工作表已确认" : "工作表已忽略", `${sheetName}`);
await refreshAfterSheetAction(result, batchId);
}
async function refreshAfterSheetAction(result, batchId) {
if (Array.isArray(result.sheets)) renderSheetList(result.sheets, batchId);
await loadImportBatches();
const button = $("#parseButton");
if (!button) return;
const pending = (result.sheets || []).filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
if (pending === 0 && (result.sheets || []).length) {
button.querySelector("span").textContent = "完成";
button.dataset.stage = "done";
} else {
button.querySelector("span").textContent = `确认全部(${pending} 个待确认)`;
}
}
async function confirmImport() {
const result = state.parseResult;
const batchId = result?.batch_id;
const sheets = Array.isArray(result?.sheets) ? result.sheets : [];
const pending = sheets
.filter((s) => s.outcome === "parsed" && s.review_status === "pending")
.map((s) => s.sheet_name);
if (!pending.length) {
$("#uploadDialog").close();
await loadImportBatches();
showView("upload");
return;
}
const button = $("#parseButton");
button.disabled = true;
button.querySelector("span").textContent = "正在确认...";
const response = await fetch(`/api/batches/${batchId}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheets: pending }),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const outcome = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
button.disabled = false;
button.querySelector("span").textContent = "确认失败,点击重试";
showToast("确认失败", outcome?.message || "请稍后重试");
return;
}
button.disabled = false;
if (Array.isArray(outcome.sheets)) renderSheetList(outcome.sheets, batchId);
await loadImportBatches();
$("#uploadDialog").close();
showView("upload");
showToast("流水已确认", `${outcome.updated.length} 个工作表已确认;未确认的工作表不参与计算`);
}
function submitImportException() {
$("#uploadDialog").close();
showView("upload");
showToast("解析异常未入账", `${state.selectedFile?.name || "该文件"} 不会进入匹配与计算,请核对模板后重新导出`);
}
function renderBatchRow(batch) {
const row = document.createElement("tr");
const idCell = document.createElement("td");
const id = document.createElement("strong");
id.textContent = `IMP-${String(batch.id).padStart(6, "0")}`;
const file = document.createElement("small");
file.textContent = batch.original_filename || "";
idCell.append(id, file);
const bank = document.createElement("td");
bank.textContent = batch.bank_name || "—";
const period = document.createElement("td");
period.textContent = batch.period_start && batch.period_end
? `${batch.period_start}${batch.period_end}`
: "—";
const count = document.createElement("td");
count.className = "number";
count.textContent = `${batch.confirmed_transactions ?? 0} 笔`;
const coverage = document.createElement("td");
const coverageStatus = batch.status === "exception"
? { className: "danger", label: "未导入" }
: batch.pending_sheets > 0
? { className: "warning", label: `${batch.pending_sheets} 个待确认` }
: batch.confirmed_sheets > 0
? { className: "success", label: "已确认" }
: { className: "neutral", label: "待处理" };
coverage.innerHTML = `<span class="status ${coverageStatus.className}">${coverageStatus.label}</span>`;
const parseState = document.createElement("td");
const statusParts = [];
if (batch.exception_sheets > 0) statusParts.push(`${batch.exception_sheets} 个异常`);
if (batch.ignored_sheets > 0) statusParts.push(`${batch.ignored_sheets} 个忽略`);
parseState.textContent = statusParts.length ? statusParts.join("、") : "解析成功";
const time = document.createElement("td");
time.textContent = String(batch.created_at || "").slice(0, 16).replace("T", " ");
row.append(idCell, bank, period, count, coverage, parseState, time);
return row;
}
async function loadImportBatches() {
const tbody = $("#importRows");
if (!tbody) return;
const response = await fetch("/api/batches").catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
const batches = Array.isArray(result?.batches) ? result.batches : [];
if (batches.length) tbody.replaceChildren(...batches.map(renderBatchRow));
}
function initCompany() {
loadCompanyManualRecords();
loadCompanyAccounts();
loadCompanyBalances();
loadCompanyDirectoryOptions();
loadImportBatches();
const uploadDialog = $("#uploadDialog");
$$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.showModal()));
$$('[data-close-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.close()));
uploadDialog?.addEventListener("close", resetUpload);
$("#accountSelect")?.addEventListener("change", updateParseButton);
$("#fileInput")?.addEventListener("change", (event) => acceptFile(event.target.files[0]));
$("#removeFile")?.addEventListener("click", () => {
state.selectedFile = null;
$("#fileInput").value = "";
$("#filePreview").hidden = true;
$("#dropzone").hidden = false;
$("#parseResult").hidden = true;
updateParseButton();
});
const dropzone = $("#dropzone");
if (dropzone) {
["dragenter", "dragover"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add("is-dragging"); }));
["dragleave", "drop"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove("is-dragging"); }));
dropzone.addEventListener("drop", (event) => acceptFile(event.dataTransfer.files[0]));
}
$("#uploadForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const stage = $("#parseButton").dataset.stage;
if (stage === "confirm" || stage === "done") {
const result = state.parseResult;
const parsed = result && ["parsed", "duplicate"].includes(result.status);
if (parsed && stage === "confirm") {
await confirmImport();
} else if (parsed) {
$("#uploadDialog").close();
showView("upload");
await loadImportBatches();
} else {
submitImportException();
}
return;
}
$("#parseButton").disabled = true;
$("#parseButton span").textContent = "正在识别表头...";
await parseFile();
});
function finishReview(button, disposition) {
const item = button.closest("article");
const type = item.dataset.reviewType;
const selectedMatch = $('input[name="matchCandidate"]:checked', item)?.value;
const selectedSubject = $("select", item)?.value;
const detail = type === "match" ? (selectedMatch || "转为匹配异常") : selectedSubject;
const history = $("#reviewHistory");
const record = document.createElement("p");
record.textContent = `${new Date().toLocaleString("zh-CN", { hour12: false })} · 牛女士 · ${disposition} · ${detail}`;
history.append(record);
history.hidden = false;
item.remove();
if (type === "match") $("#matchPendingCount").textContent = "0 笔";
else $("#subjectPendingCount").textContent = "0 笔";
const remaining = $$("#reviewList > article").length;
const badge = $('.nav-item[data-view="reconcile"] b');
if (badge) badge.textContent = remaining;
$(`.cashier-tasks [data-task-type="${type}"]`)?.remove();
const workspaceRemaining = $$(".cashier-tasks > article").length;
$("#workspacePendingStatus").textContent = `${workspaceRemaining} 项待处理`;
const workspaceBadge = $('.nav-item[data-view="workspace"] b');
if (workspaceBadge) workspaceBadge.textContent = workspaceRemaining;
showToast("处理结果已记录", "待办状态、操作人、时间和依据已同步更新");
}
$$('[data-resolve]').forEach((button) => button.addEventListener("click", () => finishReview(button, "确认")));
$$('[data-reject]').forEach((button) => button.addEventListener("click", () => finishReview(button, "转异常")));
$("#markAllRead")?.addEventListener("click", () => {
$$("#companyNotifications .is-unread").forEach((item) => {
item.classList.remove("is-unread");
const status = $(".status", item);
status.className = "status neutral";
status.textContent = "已读";
});
showToast("通知已全部标为已读");
});
$("#manualEntryForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const evidence = data.get("evidence");
if (evidence instanceof File && evidence.size > 20 * 1024 * 1024) {
showToast("证明附件超过限制", "请选择不超过 20 MB 的文件");
return;
}
submitCompanyManualRecord(form);
});
const accountDialog = $("#accountDialog");
$("#openAccountDialog")?.addEventListener("click", () => accountDialog.showModal());
$$('[data-close-account]').forEach((button) => button.addEventListener("click", () => accountDialog.close()));
$("#accountForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
// The server binds the account to the session company and normalizes the
// number; duplicates come back as 409.
const response = await fetch("/api/company/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bank_name: String(data.get("bank") || "").trim(),
account_type: String(data.get("type") || ""),
account_number: String(data.get("accountNumber") || ""),
start_date: String(data.get("startDate") || ""),
}),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("账户登记失败", result?.message || "请稍后重试");
return;
}
accountDialog.close();
event.currentTarget.reset();
await loadCompanyAccounts();
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算");
});
}
if (portal === "entry") {
initEntry();
} else {
initAuthGuard().then((allowed) => {
if (!allowed) return;
initShell();
initFlowTools();
if (portal === "admin") {
initAdmin();
} else {
initCompany();
}
});
}