2661 lines
124 KiB
JavaScript
2661 lines
124 KiB
JavaScript
const $ = (selector, scope) => {
|
||
const root = scope ?? (typeof document !== "undefined" ? document : null);
|
||
return root ? root.querySelector(selector) : null;
|
||
};
|
||
const $$ = (selector, scope) => {
|
||
const root = scope ?? (typeof document !== "undefined" ? document : null);
|
||
return root ? [...root.querySelectorAll(selector)] : [];
|
||
};
|
||
|
||
const portal = (typeof document !== "undefined" && document.body)
|
||
? (document.body.dataset.portal || "entry")
|
||
: "entry";
|
||
const viewNames = portal === "admin"
|
||
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
|
||
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", balances: "往来余额", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
|
||
|
||
const storageKeys = {
|
||
manual: "ledger-demo-manual-records",
|
||
};
|
||
|
||
const accountStatusLabels = {
|
||
pending: "待复核",
|
||
active: "已启用",
|
||
returned: "已退回",
|
||
disabled: "已停用",
|
||
};
|
||
|
||
const state = {
|
||
currentView: portal === "admin" ? "dashboard" : "workspace",
|
||
selectedFile: null,
|
||
parseResult: null,
|
||
};
|
||
|
||
const motionQuery = (typeof window !== "undefined" && window.matchMedia)
|
||
? window.matchMedia("(prefers-reduced-motion: reduce)")
|
||
: { matches: true, addEventListener() {}, removeEventListener() {} };
|
||
|
||
function animateView(view, { initial = false } = {}) {
|
||
if (!view || motionQuery.matches || typeof view.animate !== "function") return;
|
||
if (!initial) {
|
||
view.getAnimations().forEach((animation) => animation.cancel());
|
||
view.animate(
|
||
[{ opacity: 0.84, transform: "translateY(5px)" }, { opacity: 1, transform: "translateY(0)" }],
|
||
{ duration: 180, easing: "cubic-bezier(.22,1,.36,1)" },
|
||
);
|
||
return;
|
||
}
|
||
const selectors = [
|
||
".page-heading > *",
|
||
".metric-card",
|
||
".period-ribbon",
|
||
".company-alert",
|
||
".admin-dashboard-grid > *",
|
||
".company-dashboard-grid > *",
|
||
".company-ledger-panel",
|
||
".query-band",
|
||
".filter-bar",
|
||
".filter-grid",
|
||
".pair-report",
|
||
".panel",
|
||
".work-progress",
|
||
".reconcile-summary",
|
||
".account-directory > article",
|
||
];
|
||
const elements = [...new Set(selectors.flatMap((selector) => [...view.querySelectorAll(selector)]))]
|
||
.filter((element) => !element.closest(".panel") || element.matches(".panel"));
|
||
|
||
elements.forEach((element, index) => {
|
||
element.getAnimations().forEach((animation) => animation.cancel());
|
||
// metric-card 的 3D 倾斜与悬停倾斜由 CSS 控制,入场动画只做淡入,
|
||
// 否则 fill:both 的 translateY(0) 会覆盖 CSS 的 transform,导致倾斜失效。
|
||
const keyframes = element.classList.contains("metric-card")
|
||
? [{ opacity: 0 }, { opacity: 1 }]
|
||
: [
|
||
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
|
||
{ opacity: 1, transform: "translateY(0)" },
|
||
];
|
||
element.animate(keyframes, {
|
||
duration: 440,
|
||
delay: Math.min(index * 38, 260),
|
||
easing: "cubic-bezier(.22,1,.36,1)",
|
||
fill: "both",
|
||
});
|
||
});
|
||
}
|
||
|
||
function initMotion() {
|
||
if (motionQuery.matches) return;
|
||
animateView($(".app-view.is-active"), { initial: true });
|
||
|
||
$$(".nav-item", $("#sidebar") || document).forEach((item, index) => {
|
||
item.animate(
|
||
[{ opacity: 0, transform: "translateX(-8px)" }, { opacity: 1, transform: "translateX(0)" }],
|
||
{ duration: 360, delay: 90 + index * 28, easing: "cubic-bezier(.22,1,.36,1)", fill: "both" },
|
||
);
|
||
});
|
||
|
||
$$(".company-ledger").forEach((ledger) => {
|
||
ledger.addEventListener("toggle", () => {
|
||
if (!ledger.open) return;
|
||
$(".ledger-breakdown", ledger)?.animate(
|
||
[{ opacity: 0, transform: "translateY(-8px)" }, { opacity: 1, transform: "translateY(0)" }],
|
||
{ duration: 260, easing: "cubic-bezier(.22,1,.36,1)" },
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
function readStoredRecords(key) {
|
||
try {
|
||
const value = JSON.parse(localStorage.getItem(key) || "[]");
|
||
return Array.isArray(value) ? value : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function writeStoredRecords(key, records) {
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify(records));
|
||
return true;
|
||
} catch {
|
||
showToast("本机演示数据保存失败", "请检查浏览器是否允许本地存储");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function recordStatus(status) {
|
||
if (["已启用", "已确认"].includes(status)) return { className: "success", label: status };
|
||
if (status === "已退回") return { className: "danger", label: status };
|
||
if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status };
|
||
return { className: "warning", label: status || "待复核" };
|
||
}
|
||
|
||
function accountStatusLabel(status) {
|
||
return accountStatusLabels[status] || "待复核";
|
||
}
|
||
|
||
function accountTail(masked) {
|
||
return String(masked || "").replace(/^\*+/, "");
|
||
}
|
||
|
||
function formatCurrency(value) {
|
||
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function showToast(title, detail = "") {
|
||
const region = $("#toastRegion");
|
||
if (!region) return;
|
||
const toast = document.createElement("div");
|
||
toast.className = "toast";
|
||
const heading = document.createElement("strong");
|
||
heading.textContent = title;
|
||
toast.append(heading);
|
||
if (detail) {
|
||
const description = document.createElement("small");
|
||
description.textContent = detail;
|
||
toast.append(description);
|
||
}
|
||
region.append(toast);
|
||
window.setTimeout(() => toast.remove(), 3400);
|
||
}
|
||
|
||
function closeNavigation({ restoreFocus = false } = {}) {
|
||
const sidebar = $("#sidebar");
|
||
if (!sidebar) return;
|
||
const wasOpen = sidebar.classList.contains("is-open");
|
||
sidebar.classList.remove("is-open");
|
||
$$(".menu-button").forEach((button) => {
|
||
button.setAttribute("aria-expanded", "false");
|
||
button.setAttribute("aria-label", "打开导航");
|
||
});
|
||
if (restoreFocus && wasOpen) $(".menu-button")?.focus();
|
||
}
|
||
|
||
function showView(view) {
|
||
if (!viewNames[view]) return;
|
||
const navigationWasOpen = $("#sidebar")?.classList.contains("is-open");
|
||
state.currentView = view;
|
||
$$(".app-view").forEach((page) => page.classList.toggle("is-active", page.dataset.page === view));
|
||
$$(".nav-item[data-view]").forEach((item) => {
|
||
const active = item.dataset.view === view;
|
||
item.classList.toggle("is-active", active);
|
||
if (active) item.setAttribute("aria-current", "page");
|
||
else item.removeAttribute("aria-current");
|
||
});
|
||
const title = $("#currentViewName");
|
||
if (title) title.textContent = viewNames[view];
|
||
closeNavigation({ restoreFocus: navigationWasOpen });
|
||
const activeView = $(`.app-view[data-page="${view}"]`);
|
||
requestAnimationFrame(() => animateView(activeView));
|
||
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
|
||
}
|
||
|
||
const detailContent = {
|
||
"gap-a": { tag: ["danger", "高风险"], title: "A公司 · 工行账户断档", desc: "工商银行 9481 缺少 07.01—07.21 流水,已影响 7 月结账。", fields: [["公司", "A公司"], ["账户", "工商银行 · 9481"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账 · 账户覆盖 · 双边匹配"], ["当前状态", "已逾期 2 天"]], tip: "建议先向 A公司出纳发送补传提醒,补齐后在审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] },
|
||
"match-bd": { tag: ["warning", "中风险"], title: "B公司 ↔ D公司 · 单边待匹配", desc: "D公司侧流水已到,B公司侧尚未确认,合计 312.00 万元。", fields: [["本方", "B公司"], ["对方", "D公司"], ["笔数 / 金额", "6 笔 · 312.00 万元"], ["候选情况", "金额与日期存在 2 个候选"], ["当前状态", "今日新增"]], tip: "建议按账号优先核对候选流水,金额与日期相同者先确认。", action: ["去审核中心匹配", "audit"] },
|
||
"calib-f": { tag: ["warning", "中风险"], title: "F公司 · 起算区间待校准", desc: "01.01—01.16 无银行流水覆盖,公司已提交无业务说明。", fields: [["公司", "F公司"], ["账户", "农业银行 · 3650"], ["无覆盖期间", "2026.01.01—01.16"], ["现有依据", "公司已提交无业务说明"], ["当前状态", "待公司确认"]], tip: "无业务说明属于审计证据,复核通过后该区间标记为已校准,不生成银行流水。", action: ["去审核中心复核", "audit"] },
|
||
"task-upload": { tag: ["danger", "最紧急"], title: "补传工商银行流水", desc: "账户尾号 9481 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户", "工商银行 · 9481"], ["缺少期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月账户覆盖与双边匹配"], ["截止", "08.05 集团结账日前"]], tip: "从工商银行网银导出 7 月流水后直接上传,系统会自动识别表头并重新计算匹配。", action: ["去上传流水", "upload"] },
|
||
"task-match": { tag: ["warning", "待确认"], title: "确认 1 笔单边流水", desc: "07.18 转出 280.00 万元,系统找到 2 个对方候选。", fields: [["对方", "B公司"], ["日期 / 金额", "07.18 · 280.00 万元"], ["候选", "工商银行 9481(推荐)· 建设银行 2046"], ["核对点", "摘要与账号是否一致"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] },
|
||
"task-subject": { tag: ["warning", "待确认"], title: "确认往来科目", desc: "06.27 转出 600.00 万元,规则无法区分应收与其他应收。", fields: [["对方", "C公司"], ["日期 / 金额", "06.27 · 600.00 万元"], ["待确认", "应收 或 其他应收"], ["摘要", "资金调拨"]], tip: "科目只按确定性规则建议,拿不准时选“其他应收”并在说明里注明依据。", action: ["去确认科目", "reconcile"] },
|
||
"task-notice": { tag: ["neutral", "提醒"], title: "阅读总账提醒", desc: "管理员要求 08.08 前完成 7 月银行流水确认。", fields: [["来自", "系统管理员 · 今天 09:30"], ["处理期限", "2026.08.08"], ["关联事项", "断档补传 · 2 项待确认往来"]], tip: "完成补传和两项确认后,再提交公司确认即可。", action: ["查看通知", "notifications"] },
|
||
"acct-citic": { tag: ["success", "连续"], title: "中信银行 · 5316", desc: "基本户 · 起算日以来流水全部连续。", fields: [["账户类型", "基本户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "今天 09:42"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||
"acct-abc": { tag: ["success", "连续"], title: "农业银行 · 3650", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 08:01"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||
"acct-icbc": { tag: ["danger", "断档"], title: "工商银行 · 9481", desc: "一般户 · 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户类型", "一般户"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账与双边匹配"], ["最近导入", "07.31 16:18(6 月批次)"]], tip: "这是最紧急的一项:补齐后系统会自动重算覆盖与匹配。", action: ["去补传流水", "upload"] },
|
||
"acct-ccb": { tag: ["success", "连续"], title: "建设银行 · 0845", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 10:03"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||
};
|
||
|
||
function initDetailDrawer() {
|
||
const triggers = $$("[data-detail]");
|
||
if (!triggers.length) return;
|
||
const drawer = document.createElement("aside");
|
||
drawer.className = "detail-drawer";
|
||
drawer.id = "detailDrawer";
|
||
drawer.setAttribute("aria-label", "事项详情");
|
||
drawer.innerHTML = `<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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
||
}
|
||
|
||
function fmtMoney(value) {
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—";
|
||
}
|
||
|
||
function fmtAbsMoney(value) {
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—";
|
||
}
|
||
|
||
function homeCrumb() {
|
||
return portal === "admin" ? "往来查询" : "往来余额";
|
||
}
|
||
|
||
function resultDirection(signedOrDirection) {
|
||
if (signedOrDirection === "receivable" || Number(signedOrDirection) > 0) return { className: "success", label: "应收" };
|
||
if (signedOrDirection === "payable" || Number(signedOrDirection) < 0) return { className: "danger", label: "应付" };
|
||
return { className: "neutral", label: "持平" };
|
||
}
|
||
|
||
function eventIsNegative(event) {
|
||
return event.posting_kind === "reversal" || Boolean(event.is_repayment);
|
||
}
|
||
|
||
function amountWithCurrency(value, currency, { signed = false, negative = false } = {}) {
|
||
const abs = fmtAbsMoney(value);
|
||
const sign = (signed && (negative || Number(value) < 0)) ? "−" : "";
|
||
const code = currency ? `<span class="currency-code">${esc(currency)}</span>` : "";
|
||
return `<span class="amount-with-currency">${code}${sign}${abs}</span>`;
|
||
}
|
||
|
||
function accountCell(chip) {
|
||
if (!chip || chip.visibility === "missing") return '<span class="status info">源行缺失</span>';
|
||
if (chip.visibility === "masked") return `<span class="evidence-masked">${esc(chip.label || "按对方授权不可见")}</span>`;
|
||
return `<span class="account-cell">${esc(chip.label || "—")}</span>`;
|
||
}
|
||
|
||
function pickAccount(event, companyId, side) {
|
||
const isPayer = Number(event.payer_company_id) === Number(companyId);
|
||
if (side === "own") return isPayer ? event.payer_account : event.payee_account;
|
||
return isPayer ? event.payee_account : event.payer_account;
|
||
}
|
||
|
||
function eventSummaryText(event) {
|
||
const text = String(event.summary || "").trim();
|
||
if (text) return text;
|
||
const reason = String(event.reason || "").trim();
|
||
return reason ? reason.split(/\r?\n/)[0] : "—";
|
||
}
|
||
|
||
function sixLineHtml({ currency, debit, credit, signed, resultLabel, unresolved, cutoff, conservationNote }) {
|
||
const direction = resultDirection(signed);
|
||
const unresolvedBlock = unresolved && "html" in (unresolved || {}) ? unresolved : unresolvedText(unresolved);
|
||
return `
|
||
<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>${amountWithCurrency(debit, currency)}</strong></div>
|
||
<div><span>本期贷方</span><strong>${amountWithCurrency(credit, currency)}</strong></div>
|
||
<div class="pair-final"><span>期末结果</span><strong><em class="status ${direction.className}" style="margin-right:6px">${direction.label}</em>${amountWithCurrency(signed, currency)}</strong><small class="pair-open-note">${esc(resultLabel || "期间净变动")}</small></div>
|
||
<div class="pair-unresolved ${unresolvedBlock.active ? "" : "is-empty"}"><span>未决金额</span><strong>${amountWithCurrency(unresolvedBlock.gross ?? unresolved?.gross_amount ?? 0, currency)}</strong><small class="pair-open-note">${unresolvedBlock.count ?? unresolved?.count ?? 0} 笔</small></div>
|
||
<div><span>截止日</span><strong class="amount-neutral">${fmtDate(cutoff)}</strong><small class="pair-cutoff">${esc(conservationNote || "按币种独立展示")}</small></div>
|
||
</div>`;
|
||
}
|
||
|
||
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, gross: 0, count: 0 };
|
||
const reasons = Object.entries(unresolved.by_reason || {})
|
||
.map(([key, value]) => `${b44.reasonLabels[key] || key} ${fmtAbsMoney(value.gross_amount)} · ${value.count} 笔`)
|
||
.join(";");
|
||
return {
|
||
html: `<span class="status warning">未决 ${fmtAbsMoney(gross)} · ${count} 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px" title="${esc(reasons)}">${esc(reasons)}</small>`,
|
||
active: true,
|
||
gross,
|
||
count,
|
||
};
|
||
}
|
||
|
||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||
|
||
function drawerFocusables(root) {
|
||
return [...root.querySelectorAll(FOCUSABLE)].filter((node) => !node.hasAttribute("disabled") && node.getAttribute("aria-hidden") !== "true");
|
||
}
|
||
|
||
function cycleTab(index, length, shift) {
|
||
if (!length) return 0;
|
||
if (shift) return (index - 1 + length) % length;
|
||
return (index + 1) % length;
|
||
}
|
||
|
||
function drawerEscAction(depth) {
|
||
return depth > 1 ? "back" : "close";
|
||
}
|
||
|
||
function drawerState() {
|
||
const drawer = $("#evidenceDrawer");
|
||
const scrim = $("#drawerScrim");
|
||
let opener = null;
|
||
const stack = [];
|
||
|
||
function paint() {
|
||
if (!drawer || !stack.length) return;
|
||
const layer = stack[stack.length - 1];
|
||
$("#drawerTitle", drawer).textContent = layer.title;
|
||
const breadcrumb = $("#drawerBreadcrumb", drawer);
|
||
breadcrumb.replaceChildren();
|
||
layer.crumbs.forEach((crumb, index) => {
|
||
if (index) breadcrumb.append(document.createTextNode("/"));
|
||
if (crumb.action) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.textContent = crumb.label;
|
||
button.addEventListener("click", crumb.action);
|
||
breadcrumb.append(button);
|
||
} else {
|
||
const span = document.createElement("span");
|
||
span.textContent = crumb.label;
|
||
breadcrumb.append(span);
|
||
}
|
||
});
|
||
const body = $("#drawerBody", drawer);
|
||
body.innerHTML = layer.html;
|
||
if (typeof layer.onMount === "function") layer.onMount(body);
|
||
requestAnimationFrame(() => {
|
||
const nodes = drawerFocusables(drawer);
|
||
(nodes[0] || body).focus({ preventScroll: true });
|
||
if (layer.restoreSelector) {
|
||
const target = body.querySelector(layer.restoreSelector);
|
||
if (target) target.focus({ preventScroll: true });
|
||
}
|
||
});
|
||
}
|
||
|
||
function open(triggerElement) {
|
||
if (!drawer) return;
|
||
if (!stack.length) opener = triggerElement || opener;
|
||
drawer.classList.add("is-open");
|
||
drawer.setAttribute("aria-hidden", "false");
|
||
scrim?.classList.add("is-open");
|
||
}
|
||
|
||
function close({ restoreFocus = true } = {}) {
|
||
if (!drawer) return;
|
||
drawer.classList.remove("is-open");
|
||
drawer.setAttribute("aria-hidden", "true");
|
||
scrim?.classList.remove("is-open");
|
||
stack.length = 0;
|
||
if (restoreFocus && opener && document.contains(opener)) opener.focus();
|
||
opener = null;
|
||
}
|
||
|
||
function back() {
|
||
if (stack.length <= 1) {
|
||
close();
|
||
return;
|
||
}
|
||
const leaving = stack.pop();
|
||
paint();
|
||
requestAnimationFrame(() => {
|
||
const body = $("#drawerBody", drawer);
|
||
const selector = leaving.triggerSelector;
|
||
const target = selector ? body?.querySelector(selector) : null;
|
||
if (target) target.focus({ preventScroll: true });
|
||
});
|
||
}
|
||
|
||
function renderLayer(title, crumbs, html, onMount, options = {}) {
|
||
if (!drawer) return;
|
||
if (options.replaceTop && stack.length) stack.pop();
|
||
stack.push({
|
||
title,
|
||
crumbs,
|
||
html,
|
||
onMount,
|
||
triggerSelector: options.triggerSelector || null,
|
||
restoreSelector: options.restoreSelector || null,
|
||
});
|
||
paint();
|
||
}
|
||
|
||
$$("[data-close-drawer]").forEach((button) => button.addEventListener("click", () => close()));
|
||
drawer?.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
if (drawerEscAction(stack.length) === "back") back();
|
||
else close();
|
||
return;
|
||
}
|
||
if (event.key !== "Tab" || !drawer.classList.contains("is-open")) return;
|
||
const nodes = drawerFocusables(drawer);
|
||
if (!nodes.length) return;
|
||
const current = nodes.indexOf(document.activeElement);
|
||
const next = cycleTab(current < 0 ? 0 : current, nodes.length, event.shiftKey);
|
||
event.preventDefault();
|
||
nodes[next].focus();
|
||
});
|
||
return { open, close, back, renderLayer, stack };
|
||
}
|
||
|
||
const drawer = drawerState();
|
||
|
||
function regionSkeleton(rows = 3) {
|
||
return Array.from({ length: rows }, () => '<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 ? `¤cy=${encodeURIComponent(b44.currency)}` : ""}`;
|
||
}
|
||
|
||
// --- Admin: company balance directory --------------------------------------
|
||
|
||
async function loadAdminBalances(container) {
|
||
if (!container) return;
|
||
container.setAttribute("aria-busy", "true");
|
||
container.innerHTML = regionSkeleton(3);
|
||
try {
|
||
const data = await apiJson(`/api/admin/intercompany/balances?${balanceQuery()}`);
|
||
container.setAttribute("aria-busy", "false");
|
||
if (!data.items.length) {
|
||
regionEmpty(container, "该区间无往来事件", "请调整统计区间后重试", '<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 = resultDirection(item.result.direction);
|
||
const unresolved = unresolvedText(item.unresolved);
|
||
const name = item.company_name || "未命名公司";
|
||
const summary = document.createElement("summary");
|
||
summary.innerHTML = `
|
||
<span class="company-name" title="${esc(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 ledger-hide-md">${amountWithCurrency(item.period.debit, item.currency)}</strong>
|
||
<strong class="amount credit ledger-hide-md">${amountWithCurrency(item.period.credit, item.currency)}</strong>
|
||
<span class="ledger-result"><em class="status ${direction.className}">${direction.label}</em><b>${amountWithCurrency(item.result.signed_amount, item.currency)}</b></span>
|
||
<span class="ledger-unresolved ${unresolved.active ? "is-active" : "is-empty"}">${unresolved.html}</span>
|
||
<span class="ledger-cutoff">截止 ${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;
|
||
if (event.currency && item.currency && event.currency !== item.currency) continue;
|
||
const isPayer = event.payer_company_id === item.company_id;
|
||
const counterpartyId = isPayer ? event.payee_company_id : event.payer_company_id;
|
||
const counterpartyName = isPayer ? event.payee_company_name : event.payer_company_name;
|
||
const subject = subjectOf(event, item.company_id);
|
||
const key = `${counterpartyId}|${subject}|${event.currency}`;
|
||
const bucket = byPair.get(key) || { counterpartyId, counterpartyName, subject, currency: event.currency, count: 0, amount: 0, isPayer };
|
||
bucket.count += 1;
|
||
bucket.amount += Number(event.amount);
|
||
byPair.set(key, bucket);
|
||
(isPayer ? debit : credit).push(bucket);
|
||
}
|
||
if (!byPair.size) {
|
||
regionEmpty(container, "暂无已确认明细", "该公司的确认往来将在科目确认后出现");
|
||
return;
|
||
}
|
||
const section = (title, total, rows, currency) => `
|
||
<section><header><h3>${title}</h3><strong>${amountWithCurrency(total, currency)}</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)} · ${esc(row.currency)} · ${row.count} 笔</small></span>
|
||
<strong>${amountWithCurrency(row.amount, row.currency)}</strong><svg><use href="icons.svg#chevron-right"/></svg>
|
||
</button>`).join("")}
|
||
</section>`;
|
||
const debitRows = [...new Map(debit.map((row) => [`${row.counterpartyId}|${row.subject}`, row])).values()];
|
||
const creditRows = [...new Map(credit.map((row) => [`${row.counterpartyId}|${row.subject}`, row])).values()];
|
||
container.innerHTML = section("借方明细", debitRows.reduce((sum, row) => sum + row.amount, 0), debitRows, item.currency)
|
||
+ section("贷方明细", creditRows.reduce((sum, row) => sum + row.amount, 0), creditRows, item.currency);
|
||
container.querySelectorAll("[data-open-pair]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const counterpartyId = Number(button.dataset.openPair);
|
||
drawer.open(button);
|
||
renderAdminPair(item.company_id, counterpartyId, item.company_name);
|
||
});
|
||
});
|
||
} catch (error) {
|
||
regionError(container, error.message, close);
|
||
}
|
||
}
|
||
|
||
function eventTableHead() {
|
||
return `<thead><tr><th>交易日期</th><th>方向</th><th>科目</th><th>本方账户</th><th>对方账户</th><th>摘要</th><th>匹配状态</th><th class="number">金额</th></tr></thead>`;
|
||
}
|
||
|
||
function eventRowHtml(event, companyId) {
|
||
const subject = subjectOf(event, companyId) || event.own_subject;
|
||
const direction = event.direction || (event.payer_company_id === companyId ? "outgoing" : "incoming");
|
||
const posting = event.posting_kind !== "normal" ? `<small>${postingLabel(event)}</small>` : "";
|
||
const otherName = event.counterparty_company_name
|
||
|| (event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name);
|
||
const summary = eventSummaryText(event);
|
||
return `
|
||
<tr class="event-row" data-subject-row="${subject || ""}" tabindex="0" role="button"
|
||
aria-label="查看 ${fmtDate(event.effective_at)} 与 ${esc(otherName)} 的源行证据"
|
||
data-event-id="${event.ledger_event_id}">
|
||
<td>${fmtDate(event.effective_at)}${posting}</td>
|
||
<td>${directionChip(direction)}</td>
|
||
<td>${event.state === "confirmed" ? esc(b44.subjectLabels[subject] || event.own_subject_label || "") : '<span class="status info">待确认科目</span>'}</td>
|
||
<td>${accountCell(pickAccount(event, companyId, "own"))}</td>
|
||
<td>${accountCell(pickAccount(event, companyId, "counterparty"))}</td>
|
||
<td class="summary-cell" title="${esc(summary)}">${esc(summary)}</td>
|
||
<td>${eventStateChip(event.state)}</td>
|
||
<td class="number">${amountWithCurrency(event.amount, event.currency, { signed: true, negative: eventIsNegative(event) })}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function bindEventRows(root, tableId, companyId) {
|
||
$$(`#${tableId} tbody tr.event-row`, root).forEach((row) => {
|
||
const open = () => {
|
||
renderEventEvidence(Number(row.dataset.eventId), companyId, {
|
||
triggerSelector: `[data-event-id="${row.dataset.eventId}"]`,
|
||
});
|
||
};
|
||
row.addEventListener("click", open);
|
||
row.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
open();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// --- Admin: pair detail + events -------------------------------------------
|
||
|
||
async function renderAdminPair(companyId, counterpartyId, companyName, extraCrumbs = []) {
|
||
const otherName = b44.companyNames.get(counterpartyId) || "对方公司";
|
||
drawer.open();
|
||
drawer.renderLayer(
|
||
`${companyName || "公司"} ↔ ${otherName}`,
|
||
[{ label: homeCrumb(), action: () => drawer.close() }, { label: `${companyName || companyId} ↔ ${otherName}` }],
|
||
regionSkeleton(4),
|
||
);
|
||
try {
|
||
const data = await apiJson(`/api/admin/intercompany/pairs/${companyId}/${counterpartyId}?${balanceQuery()}`);
|
||
renderAdminPairBody(data, companyId, counterpartyId, extraCrumbs);
|
||
} catch (error) {
|
||
$("#drawerBody").innerHTML = `<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 a = data.companies.a;
|
||
const b = data.companies.b;
|
||
const lines = (data.items || []).map((item) => sixLineHtml({
|
||
currency: item.currency,
|
||
debit: item.a.period.debit,
|
||
credit: item.a.period.credit,
|
||
signed: item.a.result.signed_amount,
|
||
resultLabel: item.a.result.label,
|
||
unresolved: item.unresolved,
|
||
cutoff: data.window.cutoff,
|
||
conservationNote: "双方守恒已校验",
|
||
})).join("");
|
||
const first = data.items[0];
|
||
const subjectButtons = Object.values(first?.subjects || {}).map((subject) => `
|
||
<button data-subject="${subject.subject_code}" aria-pressed="false">
|
||
<span>${esc(subject.label)}</span><strong>${amountWithCurrency(Math.abs(Number(subject.a_debit) - Number(subject.a_credit)), first.currency)} · ${subject.count} 笔</strong>
|
||
</button>`).join("");
|
||
const eventCount = (data.items || []).reduce((sum, item) => sum + (item.trace?.event_count || 0), 0);
|
||
const body = `
|
||
${lines}
|
||
<div class="subject-strip">
|
||
<button class="is-active" data-subject="all" aria-pressed="true"><span>全部往来</span><strong>${eventCount} 笔</strong></button>
|
||
${subjectButtons}
|
||
</div>
|
||
<div class="table-scroll"><table class="data-table event-table" id="pairEventsTable">
|
||
${eventTableHead()}
|
||
<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: homeCrumb(), action: () => drawer.close() },
|
||
{ label: `${a.name} ↔ ${b.name}` },
|
||
],
|
||
body,
|
||
(root) => {
|
||
const urls = (data.items || []).map((item) => item.trace.events_url);
|
||
loadPairEvents(root, companyId, counterpartyId, urls);
|
||
$$("[data-subject]", root).forEach((button) => button.addEventListener("click", () => {
|
||
$$("[data-subject]", root).forEach((item) => {
|
||
const active = item === button;
|
||
item.classList.toggle("is-active", active);
|
||
item.setAttribute("aria-pressed", String(active));
|
||
});
|
||
const subject = button.dataset.subject;
|
||
$$("#pairEventsTable tbody tr[data-subject-row]").forEach((row) => {
|
||
row.hidden = subject !== "all" && row.dataset.subjectRow !== subject;
|
||
});
|
||
}));
|
||
},
|
||
{ replaceTop: true },
|
||
);
|
||
}
|
||
|
||
async function loadPairEvents(root, companyId, counterpartyId, urls) {
|
||
const tbody = $("#pairEventsTable tbody", root);
|
||
if (!tbody) return;
|
||
tbody.setAttribute("aria-busy", "true");
|
||
const pageSize = 50;
|
||
try {
|
||
const pages = await Promise.all((Array.isArray(urls) ? urls : [urls]).map((url) => apiJson(`${url}&limit=${pageSize}`)));
|
||
const items = pages.flatMap((page) => page.items || []);
|
||
tbody.setAttribute("aria-busy", "false");
|
||
if (!items.length) {
|
||
tbody.innerHTML = '<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 = items.map((event) => eventRowHtml(event, companyId)).join("");
|
||
bindEventRows(root, "pairEventsTable", companyId);
|
||
if (pages.some((page) => page.has_more)) {
|
||
const footer = document.createElement("tr");
|
||
footer.innerHTML = '<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, options = {}) {
|
||
const base = portal === "company" ? "/api/company/intercompany" : "/api/admin/intercompany";
|
||
drawer.renderLayer("事件证据", [
|
||
{ label: homeCrumb(), action: () => drawer.close() },
|
||
{ label: "返回上一层", action: () => drawer.back() },
|
||
], regionSkeleton(5), null, { triggerSelector: options.triggerSelector });
|
||
try {
|
||
const [detail, evidence] = await Promise.all([
|
||
apiJson(`${base}/events/${eventId}`),
|
||
apiJson(`${base}/events/${eventId}/evidence`),
|
||
]);
|
||
const event = detail.event;
|
||
const direction = companyId ? (event.payer_company_id === companyId ? "outgoing" : "incoming") : null;
|
||
const subjectLabel = companyId && event.subject_code ? (b44.subjectLabels[subjectOf(event, companyId)] || "") : (b44.subjectLabels[event.subject_code] || "");
|
||
const blocks = evidence.blocks.map(renderEvidenceBlock).join('<div class="evidence-midline">双边匹配 · 已去重为同一事件</div>');
|
||
drawer.renderLayer(
|
||
`${fmtDate(event.effective_at)} 事件`,
|
||
[
|
||
{ label: homeCrumb(), action: () => drawer.close() },
|
||
{ label: `${event.payer_company_name} ↔ ${event.payee_company_name}`, action: () => drawer.back() },
|
||
{ label: fmtDate(event.effective_at) },
|
||
],
|
||
`
|
||
<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>${amountWithCurrency(event.amount, event.currency, { signed: true, negative: eventIsNegative(event) })}</strong></div>
|
||
<div><span>匹配状态</span><strong>${eventStateChip(detail.state)}</strong></div>
|
||
</div>
|
||
<div class="evidence-stack">${blocks}</div>`,
|
||
null,
|
||
{ replaceTop: true, triggerSelector: options.triggerSelector },
|
||
);
|
||
} 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, options));
|
||
}
|
||
}
|
||
|
||
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.dataset.suggestions = JSON.stringify(item.suggestions || []);
|
||
row.dataset.summary = item.summary || "";
|
||
row.dataset.amount = String(item.amount || "");
|
||
row.dataset.currency = item.currency || "";
|
||
row.dataset.effectiveAt = item.effective_at || "";
|
||
row.innerHTML = `
|
||
<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.dataset.submittedBy = record.submitted_by_username || "";
|
||
row.dataset.attachmentName = record.attachment_name || "";
|
||
row.dataset.reasonText = record.reason || "";
|
||
row.dataset.summary = record.summary || "";
|
||
row.dataset.amount = String(record.amount || "");
|
||
row.dataset.currency = record.currency || "";
|
||
row.innerHTML = `
|
||
<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);
|
||
}
|
||
|
||
function fillAuditEvidence(evidence, row, cells) {
|
||
const heading = document.createElement("strong");
|
||
heading.textContent = cells[1].querySelector("strong")?.textContent || "";
|
||
evidence.append(heading);
|
||
if (row.dataset.recordKind === "subject-review") {
|
||
const meta = document.createElement("small");
|
||
meta.textContent = `${fmtDate(row.dataset.effectiveAt)} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim();
|
||
evidence.append(meta);
|
||
if (row.dataset.summary) {
|
||
const summary = document.createElement("small");
|
||
summary.textContent = `摘要:${row.dataset.summary}`;
|
||
evidence.append(summary);
|
||
}
|
||
const suggestions = JSON.parse(row.dataset.suggestions || "[]");
|
||
const list = document.createElement("ul");
|
||
list.className = "candidate-list";
|
||
if (!suggestions.length) {
|
||
const item = document.createElement("li");
|
||
item.textContent = "无规则建议(需退回或转异常,不能猜测科目)";
|
||
list.append(item);
|
||
} else {
|
||
suggestions.forEach((suggestion) => {
|
||
const item = document.createElement("li");
|
||
item.textContent = `规则建议(只读):${suggestion.suggested_company_name || ""} · ${suggestion.suggested_subject_label || suggestion.suggested_subject_code || ""}`;
|
||
list.append(item);
|
||
});
|
||
}
|
||
evidence.append(list);
|
||
return;
|
||
}
|
||
if (row.dataset.recordKind === "manual-review") {
|
||
const meta = document.createElement("small");
|
||
meta.textContent = `提交人 ${row.dataset.submittedBy || "—"} · 附件 ${row.dataset.attachmentName || "无"}`;
|
||
evidence.append(meta);
|
||
const summary = document.createElement("small");
|
||
summary.textContent = `摘要:${row.dataset.summary || "—"} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim();
|
||
evidence.append(summary);
|
||
const reason = document.createElement("p");
|
||
reason.className = "reason-full";
|
||
reason.textContent = row.dataset.reasonText || cells[1].querySelector("small")?.textContent || "";
|
||
evidence.append(reason);
|
||
return;
|
||
}
|
||
const detail = document.createElement("small");
|
||
detail.textContent = cells[1].querySelector("small")?.textContent || "";
|
||
const source = document.createElement("small");
|
||
source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`;
|
||
evidence.append(detail, source);
|
||
}
|
||
|
||
async function loadAdminAuditQueue() {
|
||
try {
|
||
const [subjects, records] = await Promise.all([
|
||
apiJson(`/api/admin/subject-reviews?${balanceQuery()}&limit=100`),
|
||
apiJson("/api/admin/manual-records?limit=100"),
|
||
]);
|
||
$$("#auditRows [data-record-kind='subject-review'], #auditRows [data-record-kind='manual-review']").forEach((row) => row.remove());
|
||
(subjects.items || []).forEach(appendSubjectReviewRow);
|
||
(records.records || []).filter((record) => record.state === "pending").forEach(appendManualRecordReviewRow);
|
||
updateAuditCounts();
|
||
} catch {
|
||
/* the demo audit table stays visible if the queue cannot load */
|
||
}
|
||
}
|
||
|
||
// --- Company portal: balances ------------------------------------------------
|
||
|
||
async function loadCompanyBalances() {
|
||
const summaryLine = $("#companyBalanceGroups") || $("#companyBalanceLine");
|
||
const counterpartyList = $("#companyCounterparties");
|
||
const alertBox = $("#companyUnresolvedAlert");
|
||
if (!summaryLine && !counterpartyList) return;
|
||
if (summaryLine) summaryLine.innerHTML = regionSkeleton(1);
|
||
if (counterpartyList) counterpartyList.innerHTML = regionSkeleton(2);
|
||
try {
|
||
const data = await apiJson(`/api/company/intercompany/balances?${balanceQuery()}`);
|
||
$("#companyBalancePeriod").textContent = `统计口径 ${fmtDate(data.window.from)}—${fmtDate(data.window.cutoff)} · 期初不可用,仅显示期间净变动`;
|
||
if (summaryLine) {
|
||
const items = data.items || [];
|
||
if (!items.length) {
|
||
summaryLine.innerHTML = sixLineHtml({
|
||
currency: "", debit: 0, credit: 0, signed: 0, resultLabel: "期间净变动",
|
||
unresolved: { gross_amount: "0", count: 0 }, cutoff: data.window.cutoff,
|
||
});
|
||
} else {
|
||
summaryLine.innerHTML = items.map((item) => sixLineHtml({
|
||
currency: item.currency,
|
||
debit: item.period.debit,
|
||
credit: item.period.credit,
|
||
signed: item.result.signed_amount,
|
||
resultLabel: item.result.label,
|
||
unresolved: item.unresolved,
|
||
cutoff: item.window?.cutoff || data.window.cutoff,
|
||
})).join("");
|
||
}
|
||
}
|
||
const unresolvedCount = (data.items || []).reduce((sum, item) => sum + (item.unresolved?.count || 0), 0);
|
||
if (alertBox) {
|
||
if (unresolvedCount) {
|
||
alertBox.hidden = false;
|
||
const parts = (data.items || [])
|
||
.filter((item) => item.unresolved?.count)
|
||
.map((item) => `${item.currency} ${fmtAbsMoney(item.unresolved.gross_amount)}`);
|
||
$("#companyUnresolvedText").textContent = `有 ${unresolvedCount} 笔往来未确认,合计 ${parts.join("、")},不影响已确认余额`;
|
||
} else {
|
||
alertBox.hidden = true;
|
||
}
|
||
}
|
||
renderCompanyCounterparties(counterpartyList, data.counterparties || [], data.window.cutoff);
|
||
} catch (error) {
|
||
if (summaryLine) regionError(summaryLine, error.message, loadCompanyBalances);
|
||
if (counterpartyList) counterpartyList.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
function renderCompanyCounterparties(container, counterparties, cutoff) {
|
||
if (!container) return;
|
||
if (!counterparties.length) {
|
||
regionEmpty(container, "暂无对方公司往来", "确认往来事件后,对方公司明细将在此展示");
|
||
return;
|
||
}
|
||
container.replaceChildren(...counterparties.map((row) => {
|
||
const article = document.createElement("article");
|
||
article.className = "company-row is-balance-counterparty";
|
||
article.tabIndex = 0;
|
||
article.dataset.counterpartyId = row.counterparty_company_id;
|
||
article.setAttribute("role", "button");
|
||
article.setAttribute("aria-label", `查看与 ${row.counterparty_company_name} 的往来明细`);
|
||
const direction = resultDirection(row.result.direction);
|
||
const unresolved = unresolvedText(row.unresolved);
|
||
article.innerHTML = `
|
||
<span class="company-row-mark">${esc(row.counterparty_company_name.slice(0, 1))}</span>
|
||
<div class="company-row-body"><strong title="${esc(row.counterparty_company_name)}">${esc(row.counterparty_company_name)}</strong><small class="currency-tag">${esc(row.currency)} · ${unresolved.active ? `未决 ${fmtAbsMoney(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> ${amountWithCurrency(row.result.signed_amount, row.currency)}</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.open();
|
||
drawer.renderLayer(`${counterpartyName} · 往来明细`, [{ label: homeCrumb(), action: () => drawer.close() }], regionSkeleton(4));
|
||
try {
|
||
const data = await apiJson(`/api/company/intercompany/pairs/${counterpartyId}?${balanceQuery()}`);
|
||
const own = data.companies.a;
|
||
const lines = (data.items || []).map((item) => sixLineHtml({
|
||
currency: item.currency,
|
||
debit: item.a.period.debit,
|
||
credit: item.a.period.credit,
|
||
signed: item.a.result.signed_amount,
|
||
resultLabel: item.a.result.label,
|
||
unresolved: item.unresolved,
|
||
cutoff: data.window.cutoff,
|
||
conservationNote: "双方守恒已校验",
|
||
})).join("");
|
||
const body = `
|
||
${lines}
|
||
<div class="table-scroll"><table class="data-table event-table" id="companyEventsTable">
|
||
${eventTableHead()}
|
||
<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(
|
||
`${own.name} ↔ ${counterpartyName}`,
|
||
[{ label: homeCrumb(), action: () => drawer.close() }, { label: `${own.name} ↔ ${counterpartyName}` }],
|
||
body,
|
||
(root) => loadCompanyEvents(root, counterpartyId),
|
||
{ replaceTop: true },
|
||
);
|
||
} 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="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 = filtered.map((event) => eventRowHtml(event, ownCompanyId)).join("");
|
||
bindEventRows(root, "companyEventsTable", ownCompanyId);
|
||
} catch (error) {
|
||
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>`;
|
||
}
|
||
}
|
||
|
||
// --- B-44 wiring -------------------------------------------------------------
|
||
|
||
function initBalanceQueries() {
|
||
$$("[data-balance-form]").forEach((form) => {
|
||
form.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const from = $('[name="from"]', form).value;
|
||
const cutoff = $('[name="cutoff"]', form).value;
|
||
const currency = $('[name="currency"]', form).value || "";
|
||
if (from > cutoff) {
|
||
showToast("日期范围无效", "统计起始不能晚于统计截止");
|
||
return;
|
||
}
|
||
b44.from = from;
|
||
b44.cutoff = cutoff;
|
||
b44.currency = currency;
|
||
loadAdminBalances($("#balanceLedgers"));
|
||
showToast("查询结果已更新", `${from} 至 ${cutoff} · 截止日口径`);
|
||
});
|
||
});
|
||
|
||
// Dashboard quick-pair form: resolve company names to ids and open the pair.
|
||
$$("[data-pair-form]").forEach((form) => form.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const from = $('[name="from"]', form).value;
|
||
const to = $('[name="to"]', form).value;
|
||
if (from === to) {
|
||
showToast("请选择两个不同的公司", "同公司账户调拨不进入公司间往来查询");
|
||
return;
|
||
}
|
||
const fromId = [...b44.companyNames.entries()].find(([, name]) => name === from)?.[0];
|
||
const toId = [...b44.companyNames.entries()].find(([, name]) => name === to)?.[0];
|
||
if (!fromId || !toId) {
|
||
showToast("无法解析公司主档", "请先在「公司与账号」中建立公司");
|
||
return;
|
||
}
|
||
renderAdminPair(fromId, toId, from);
|
||
}));
|
||
|
||
// Dashboard quick-pair buttons and static pair links resolve to the drawer.
|
||
$$("[data-pair-link]").forEach((button) => button.addEventListener("click", () => {
|
||
const [fromName, toName] = String(button.dataset.pairLink || "").split("|");
|
||
const fromId = [...b44.companyNames.entries()].find(([, name]) => name === fromName)?.[0];
|
||
const toId = [...b44.companyNames.entries()].find(([, name]) => name === toName)?.[0];
|
||
if (!fromId || !toId) {
|
||
showToast("无法解析演示公司", "正式数据请先在「公司与账号」中建立公司主档");
|
||
return;
|
||
}
|
||
renderAdminPair(fromId, toId, fromName);
|
||
}));
|
||
}
|
||
|
||
async function loadCompanyOptions() {
|
||
try {
|
||
const data = await apiJson("/api/admin/companies");
|
||
(data.companies || []).forEach((company) => b44.companyNames.set(company.id, company.name));
|
||
} catch {
|
||
/* best-effort name resolution for dashboard pair links */
|
||
}
|
||
}
|
||
|
||
async function loadCompanyDirectoryOptions() {
|
||
try {
|
||
const data = await apiJson("/api/company/companies");
|
||
(data.companies || []).forEach((company) => b44.companyNames.set(company.id, company.name));
|
||
const select = $("#manualEntryForm [name='counterparty']");
|
||
if (select) {
|
||
const kept = [...select.options].filter((option) => option.dataset.keep);
|
||
select.replaceChildren(...kept);
|
||
(data.companies || []).forEach((company) => select.add(new Option(company.name, company.id)));
|
||
}
|
||
} catch {
|
||
/* the manual form keeps the free-text counterparty as fallback */
|
||
}
|
||
}
|
||
|
||
async function submitCompanyManualRecord(form) {
|
||
const data = new FormData(form);
|
||
const sourceOption = $('[name="sourceAccount"] option:checked', form);
|
||
const counterpartyOption = $('[name="counterparty"] option:checked', form);
|
||
const payload = {
|
||
counterparty_company_id: Number(counterpartyOption?.value || 0) || null,
|
||
occurred_at: `${String(data.get("transactionDate"))}T12:00:00`,
|
||
direction: String(data.get("direction")) === "付款" ? "outgoing" : "incoming",
|
||
amount: String(data.get("amount")),
|
||
currency: "CNY",
|
||
funding_source: sourceOption?.dataset.accountId ? "approved_bank_account" : "other",
|
||
bank_account_id: sourceOption?.dataset.accountId ? Number(sourceOption.dataset.accountId) : null,
|
||
requested_subject: b44.subjectCodes[String(data.get("subject"))] || "other_receivable",
|
||
summary: String(data.get("summary") || "").trim(),
|
||
reason: String(data.get("remark") || "").trim(),
|
||
request_key: `mr-${Date.now().toString(36)}`,
|
||
};
|
||
const evidenceFile = data.get("evidence");
|
||
if (evidenceFile instanceof File && evidenceFile.name) {
|
||
payload.evidence = { attachment_name: evidenceFile.name };
|
||
}
|
||
if (!payload.counterparty_company_id) {
|
||
showToast("请选择对方公司", "手工记录对方必须是集团内部公司");
|
||
return;
|
||
}
|
||
if (!payload.amount || Number(payload.amount) <= 0) {
|
||
showToast("金额无效", "金额必须大于零");
|
||
return;
|
||
}
|
||
try {
|
||
const result = await apiJson("/api/company/manual-records", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
form.reset();
|
||
showToast("手工记录已提交", "总账复核前不会纳入公司间往来计算");
|
||
await loadCompanyManualRecords();
|
||
return result;
|
||
} catch (error) {
|
||
showToast("手工记录提交失败", error.message);
|
||
}
|
||
}
|
||
|
||
async function loadCompanyManualRecords() {
|
||
const tbody = $("#manualRecordRows");
|
||
if (!tbody) return;
|
||
try {
|
||
const data = await apiJson("/api/company/manual-records?limit=100");
|
||
$$('[data-stored-record]', tbody).forEach((row) => row.remove());
|
||
(data.records || []).forEach((record) => appendCompanyManualRow(record));
|
||
const pending = (data.records || []).filter((record) => record.state === "pending").length;
|
||
const status = $("#manualPendingStatus");
|
||
if (status) status.textContent = `${pending} 笔待总账复核`;
|
||
} catch {
|
||
/* keep the demo rows when the API is unavailable */
|
||
}
|
||
}
|
||
|
||
function appendCompanyManualRow(record) {
|
||
const tbody = $("#manualRecordRows");
|
||
if (!tbody) return;
|
||
const row = document.createElement("tr");
|
||
row.dataset.storedRecord = record.id;
|
||
const identity = document.createElement("td");
|
||
const id = document.createElement("strong"); id.textContent = `MR-${record.id}`;
|
||
const date = document.createElement("small"); date.textContent = fmtDate(record.occurred_at);
|
||
identity.append(id, date);
|
||
const direction = document.createElement("td");
|
||
const directionName = document.createElement("strong"); directionName.textContent = record.direction === "outgoing" ? "付款" : "收款";
|
||
const subject = document.createElement("small"); subject.textContent = b44.subjectLabels[record.requested_subject] || record.requested_subject;
|
||
direction.append(directionName, subject);
|
||
const source = document.createElement("td"); source.textContent = { approved_bank_account: "已批准账户", personal_transit: "个人过账", other: "其他来源" }[record.funding_source] || record.funding_source;
|
||
const counterparty = document.createElement("td");
|
||
const counterpartyName = document.createElement("strong"); counterpartyName.textContent = record.counterparty_company_name;
|
||
counterparty.append(counterpartyName);
|
||
const summary = document.createElement("td"); summary.textContent = record.summary || record.reason || "—";
|
||
const amount = document.createElement("td"); amount.className = "number"; amount.textContent = fmtMoney(record.amount);
|
||
const statusCell = document.createElement("td");
|
||
const stateMap = { pending: ["warning", "待总账复核"], approved: ["success", "已确认"], returned: ["danger", "已退回"], exception: ["neutral", "异常待处理"], reversed: ["neutral", "已冲销"] };
|
||
const [className, label] = stateMap[record.state] || ["neutral", record.state];
|
||
const badge = document.createElement("span"); badge.className = `status ${className}`; badge.textContent = label;
|
||
statusCell.append(badge);
|
||
row.append(identity, direction, source, counterparty, summary, amount, statusCell);
|
||
tbody.append(row);
|
||
}
|
||
|
||
function renderCompanyManualRecords() {
|
||
const tbody = $("#manualRecordRows");
|
||
if (!tbody) return;
|
||
$$('[data-stored-record]', tbody).forEach((row) => row.remove());
|
||
const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司");
|
||
[...records].reverse().forEach((record) => {
|
||
const row = document.createElement("tr");
|
||
row.dataset.storedRecord = record.id;
|
||
const identity = document.createElement("td");
|
||
const id = document.createElement("strong"); id.textContent = record.id;
|
||
const date = document.createElement("small"); date.textContent = record.transactionDate;
|
||
identity.append(id, date);
|
||
const direction = document.createElement("td");
|
||
const directionName = document.createElement("strong"); directionName.textContent = record.direction;
|
||
const subject = document.createElement("small"); subject.textContent = record.subject;
|
||
direction.append(directionName, subject);
|
||
const source = document.createElement("td"); source.textContent = record.sourceAccount;
|
||
const counterparty = document.createElement("td");
|
||
const counterpartyName = document.createElement("strong"); counterpartyName.textContent = record.counterparty;
|
||
const counterpartyType = document.createElement("small"); counterpartyType.textContent = record.counterpartyType;
|
||
counterparty.append(counterpartyName, counterpartyType);
|
||
const summary = document.createElement("td"); summary.textContent = record.summary;
|
||
const amount = document.createElement("td"); amount.className = "number"; amount.textContent = formatCurrency(record.amount);
|
||
const statusCell = document.createElement("td");
|
||
const status = recordStatus(record.status);
|
||
const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label;
|
||
statusCell.append(badge);
|
||
row.append(identity, direction, source, counterparty, summary, amount, statusCell);
|
||
tbody.append(row);
|
||
});
|
||
const pending = records.filter((record) => record.status === "待总账复核").length + 1;
|
||
if ($("#manualPendingStatus")) $("#manualPendingStatus").textContent = `${pending} 笔待总账复核`;
|
||
}
|
||
|
||
function fillAccountSelects(accounts) {
|
||
const usable = accounts.filter((account) => account.usable);
|
||
[$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => {
|
||
if (!select) return;
|
||
const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账");
|
||
select.replaceChildren(...kept);
|
||
usable.forEach((account) => {
|
||
const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`;
|
||
const option = new Option(value, value);
|
||
option.dataset.accountId = account.id;
|
||
select.add(option);
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderCompanyAccounts(accounts) {
|
||
const directory = $("#accountDirectory");
|
||
if (directory) {
|
||
directory.replaceChildren(...accounts.map((account) => {
|
||
const article = document.createElement("article");
|
||
const header = document.createElement("header");
|
||
const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = account.bank_name.slice(0, 1);
|
||
const identity = document.createElement("div");
|
||
const name = document.createElement("strong"); name.textContent = account.bank_name;
|
||
const meta = document.createElement("small"); meta.textContent = `${account.account_type} · 尾号 ${accountTail(account.account_number_masked)}`;
|
||
identity.append(name, meta);
|
||
const status = recordStatus(accountStatusLabel(account.status));
|
||
const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label;
|
||
header.append(mark, identity, badge);
|
||
const details = document.createElement("dl");
|
||
const rows = [
|
||
["申请启用", account.effective_from || "待审核确定"],
|
||
["流水覆盖", account.usable ? "尚未上传" : "不参与计算"],
|
||
["提交时间", String(account.created_at || "").slice(0, 10) || "—"],
|
||
];
|
||
if (account.status === "returned" && account.review_reason) rows.push(["退回原因", account.review_reason]);
|
||
if (account.status === "disabled" && account.effective_to) rows.push(["停用日期", account.effective_to]);
|
||
rows.forEach(([term, value]) => {
|
||
const wrapper = document.createElement("div");
|
||
const dt = document.createElement("dt"); dt.textContent = term;
|
||
const dd = document.createElement("dd"); dd.textContent = value;
|
||
wrapper.append(dt, dd); details.append(wrapper);
|
||
});
|
||
article.append(header, details);
|
||
return article;
|
||
}));
|
||
}
|
||
fillAccountSelects(accounts);
|
||
}
|
||
|
||
async function loadCompanyAccounts() {
|
||
const response = await fetch("/api/company/accounts").catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
if (!response?.ok) return;
|
||
const result = await response.json().catch(() => null);
|
||
if (result?.accounts) renderCompanyAccounts(result.accounts);
|
||
}
|
||
|
||
function appendAdminReviewRow(record, kind) {
|
||
const tbody = $("#auditRows");
|
||
if (!tbody) return;
|
||
const isAccount = kind === "account";
|
||
// Account rows come from the server (full number visible only in this
|
||
// authorized admin view); manual records are still browser-local demo data.
|
||
const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status;
|
||
const row = document.createElement("tr");
|
||
row.dataset.storedReview = record.id;
|
||
row.dataset.recordId = record.id;
|
||
row.dataset.recordKind = kind;
|
||
if (isAccount) row.dataset.accountId = record.id;
|
||
row.dataset.auditType = isAccount ? "账户" : "手工";
|
||
row.dataset.company = isAccount ? record.company_name : record.company;
|
||
row.dataset.evidence = isAccount
|
||
? "公司提交资料、开户行、账号、账户类型与启用日期"
|
||
: "公司手工记录、关联银行流水号、证明附件与提交说明";
|
||
if (statusLabel !== "待复核" && statusLabel !== "待总账复核") row.dataset.resolved = "true";
|
||
|
||
const riskCell = document.createElement("td");
|
||
const risk = document.createElement("span"); risk.className = "task-level warning"; risk.textContent = "中"; riskCell.append(risk);
|
||
const identityCell = document.createElement("td");
|
||
const identity = document.createElement("strong");
|
||
const detail = document.createElement("small");
|
||
const typeCell = document.createElement("td");
|
||
const periodCell = document.createElement("td");
|
||
const impactCell = document.createElement("td");
|
||
if (isAccount) {
|
||
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
|
||
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
|
||
typeCell.textContent = "账户登记";
|
||
periodCell.textContent = record.effective_from || "待审核确定";
|
||
impactCell.textContent = "账户识别与流水上传";
|
||
} else {
|
||
identity.textContent = `${record.company} · ${record.id}`;
|
||
detail.textContent = `${record.direction} ${formatCurrency(record.amount)} 元 · ${record.counterparty} · ${record.subject}`;
|
||
typeCell.textContent = "手工记录";
|
||
periodCell.textContent = record.transactionDate;
|
||
impactCell.textContent = `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`;
|
||
}
|
||
identityCell.append(identity, detail);
|
||
const statusCell = document.createElement("td");
|
||
const status = recordStatus(statusLabel);
|
||
const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label; statusCell.append(badge);
|
||
const actionCell = document.createElement("td");
|
||
const action = document.createElement("button"); action.className = "text-button"; action.dataset.auditAction = "";
|
||
if (isAccount) row.dataset.accountStatus = record.status;
|
||
action.textContent = row.dataset.resolved
|
||
? (isAccount && record.status === "active" ? "管理" : "查看记录")
|
||
: "复核";
|
||
if (row.dataset.resolved) {
|
||
const decisionLabels = { active: "复核通过并启用账户", returned: "退回公司修改", disabled: "停用并驳回" };
|
||
const decisionLabel = isAccount ? decisionLabels[record.status] || statusLabel : record.decision || record.status;
|
||
const reasonText = (isAccount ? record.review_reason : record.reviewReason) || "已留痕";
|
||
action.dataset.record = `${decisionLabel} · ${reasonText}`;
|
||
action.dataset.decision = decisionLabel;
|
||
action.dataset.reason = reasonText;
|
||
action.dataset.processedAt = (isAccount ? record.reviewed_at : record.reviewedAt) || "时间未记录";
|
||
}
|
||
actionCell.append(action);
|
||
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
|
||
tbody.append(row);
|
||
}
|
||
|
||
async function renderAdminAccountReviews() {
|
||
const tbody = $("#auditRows");
|
||
if (!tbody) return;
|
||
$$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove());
|
||
const response = await fetch("/api/admin/accounts").catch(() => null);
|
||
if (!response?.ok) return;
|
||
const result = await response.json().catch(() => null);
|
||
(result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
||
updateAuditCounts();
|
||
}
|
||
|
||
function renderStoredAdminReviews() {
|
||
if (!$("#auditRows")) return;
|
||
$$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove());
|
||
readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual"));
|
||
renderAdminAccountReviews();
|
||
}
|
||
|
||
function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) {
|
||
if (kind !== "manual" || !id) return;
|
||
const records = readStoredRecords(storageKeys.manual);
|
||
const record = records.find((item) => item.id === id);
|
||
if (!record) return;
|
||
Object.assign(record, { status, decision, reviewReason, reviewedAt });
|
||
writeStoredRecords(storageKeys.manual, records);
|
||
}
|
||
|
||
function updateAuditCounts() {
|
||
const rows = $$("#auditRows tr");
|
||
const unresolved = rows.filter((row) => row.dataset.resolved !== "true");
|
||
$$('[data-audit-filter]').forEach((button) => {
|
||
const type = button.dataset.auditFilter;
|
||
const count = unresolved.filter((row) => type === "all" || row.dataset.auditType === type).length;
|
||
button.textContent = `${button.dataset.label} ${count}`;
|
||
});
|
||
const badge = $('.nav-item[data-view="audit"] b');
|
||
if (badge) badge.textContent = unresolved.length;
|
||
}
|
||
|
||
function companyStatusBadge(status) {
|
||
if (status === "preparing") return { className: "neutral", label: "筹备中" };
|
||
if (status === "disabled") return { className: "danger", label: "已停用" };
|
||
return { className: "success", label: "正常" };
|
||
}
|
||
|
||
function renderAdminCompanyTable(companies) {
|
||
const tbody = $("#companyTable tbody");
|
||
if (!tbody) return;
|
||
tbody.replaceChildren(...companies.map((company) => {
|
||
const row = document.createElement("tr");
|
||
const nameCell = document.createElement("td");
|
||
const name = document.createElement("strong"); name.textContent = company.name;
|
||
const code = document.createElement("small"); code.textContent = `COMP-${String(company.id).padStart(3, "0")}`;
|
||
nameCell.append(name, code);
|
||
const credit = document.createElement("td"); credit.textContent = company.credit_code || "待补充";
|
||
const accounts = document.createElement("td"); accounts.textContent = `${company.account_count ?? 0} 个`;
|
||
const usernames = document.createElement("td"); usernames.textContent = company.usernames || "未创建";
|
||
const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定";
|
||
const statusCell = document.createElement("td");
|
||
const badge = companyStatusBadge(company.status);
|
||
statusCell.innerHTML = `<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($("#balanceLedgers"));
|
||
});
|
||
loadAdminAuditQueue();
|
||
const companySearch = $('[data-filter-target="companyLedgers"]');
|
||
companySearch?.addEventListener("input", () => {
|
||
const query = companySearch.value.trim().toLowerCase();
|
||
$$("#companyLedgers .company-ledger").forEach((item) => { item.hidden = query ? !item.textContent.toLowerCase().includes(query) : false; });
|
||
});
|
||
|
||
let activeAuditType = "all";
|
||
function filterAuditRows() {
|
||
const company = $("#auditCompany")?.value || "全部公司";
|
||
$$(".audit-table tbody tr").forEach((row) => {
|
||
const typeMatches = activeAuditType === "all" || row.dataset.auditType === activeAuditType;
|
||
const companyMatches = company === "全部公司" || row.dataset.company === company;
|
||
row.hidden = !(typeMatches && companyMatches);
|
||
});
|
||
}
|
||
$$("[data-audit-filter]").forEach((button) => button.addEventListener("click", () => {
|
||
activeAuditType = button.dataset.auditFilter;
|
||
$$("[data-audit-filter]").forEach((item) => {
|
||
const active = item === button;
|
||
item.classList.toggle("is-active", active);
|
||
item.setAttribute("aria-pressed", String(active));
|
||
});
|
||
filterAuditRows();
|
||
}));
|
||
$("#auditCompany")?.addEventListener("change", filterAuditRows);
|
||
|
||
const TRIAGE_DECISIONS = ["确认并纳入计算", "退回公司补充材料", "转为异常"];
|
||
const auditDialog = $("#auditDialog");
|
||
const syncExceptionNote = () => {
|
||
const note = $("#auditExceptionNote");
|
||
const decision = $('#auditForm [name="decision"]');
|
||
if (note) note.hidden = !String(decision?.value || "").includes("转为异常");
|
||
};
|
||
$$('[data-close-audit]').forEach((button) => button.addEventListener("click", () => auditDialog.close()));
|
||
$("#auditForm [name='decision']")?.addEventListener("change", syncExceptionNote);
|
||
// Delegated: account review rows arrive asynchronously from the API.
|
||
$("#auditRows")?.addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-audit-action]");
|
||
if (!button) return;
|
||
const row = button.closest("tr");
|
||
state.auditRow = row;
|
||
const cells = $$('td', row);
|
||
const form = $("#auditForm");
|
||
const decision = $('[name="decision"]', form);
|
||
const reason = $('[name="reason"]', form);
|
||
const submit = $('button[type="submit"]', form);
|
||
form.reset();
|
||
const isActiveAccount = row.dataset.recordKind === "account" && row.dataset.accountStatus === "active";
|
||
const decisions = row.dataset.recordKind === "account"
|
||
? (isActiveAccount ? ["停用并驳回"] : ["复核通过并启用账户", "退回公司修改", "停用并驳回"])
|
||
: (row.dataset.recordKind === "subject-review" || row.dataset.recordKind === "manual-review")
|
||
? TRIAGE_DECISIONS
|
||
: TRIAGE_DECISIONS;
|
||
decision.replaceChildren(new Option("请选择", ""), ...decisions.map((item) => new Option(item, item)));
|
||
decision.disabled = false;
|
||
reason.disabled = false;
|
||
submit.hidden = false;
|
||
$("#auditDialogTitle").textContent = `${cells[2].innerText.trim()} · ${cells[1].querySelector("strong").textContent}`;
|
||
$("#auditDialogMeta").textContent = `${cells[3].innerText.trim()} · 影响 ${cells[4].innerText.trim()}`;
|
||
const evidence = $("#auditEvidence");
|
||
evidence.replaceChildren();
|
||
fillAuditEvidence(evidence, row, cells);
|
||
syncExceptionNote();
|
||
if (button.dataset.record && !isActiveAccount) {
|
||
decision.value = button.dataset.decision;
|
||
reason.value = button.dataset.reason;
|
||
decision.disabled = true;
|
||
reason.disabled = true;
|
||
submit.hidden = true;
|
||
$("#auditDialogMeta").textContent = `已处理 · ${button.dataset.processedAt} · 系统管理员`;
|
||
const record = document.createElement("small");
|
||
record.textContent = `处理记录:${button.dataset.record}`;
|
||
evidence.append(record);
|
||
syncExceptionNote();
|
||
}
|
||
auditDialog.showModal();
|
||
});
|
||
$("#auditForm")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const data = new FormData(event.currentTarget);
|
||
const row = state.auditRow;
|
||
const decision = String(data.get("decision"));
|
||
const reason = String(data.get("reason"));
|
||
|
||
// B-44 subject review: confirm uses the rule suggestion; return/exception park the event.
|
||
if (row.dataset.recordKind === "subject-review") {
|
||
const actionMap = {
|
||
"确认并纳入计算": "confirm",
|
||
"退回公司补充材料": "return",
|
||
"转为异常": "exception",
|
||
};
|
||
const action = actionMap[decision];
|
||
if (!action) {
|
||
showToast("请选择处理决定", "科目审核仅支持确认、退回或转异常");
|
||
return;
|
||
}
|
||
const suggestions = JSON.parse(row.dataset.suggestions || "[]");
|
||
const suggested = suggestions[0];
|
||
if (action === "confirm" && !suggested?.suggested_subject_code) {
|
||
showToast("没有可入账的规则建议", "请退回公司补充材料或转为异常,不要猜测科目");
|
||
return;
|
||
}
|
||
const body = {
|
||
perspective_company_id: Number(suggested?.suggested_perspective_company_id || row.dataset.perspectiveId),
|
||
reason,
|
||
expected_revision: Number(row.dataset.expectedRevision),
|
||
request_key: `subj-${row.dataset.ledgerEventId}-${Date.now().toString(36)}`,
|
||
};
|
||
if (action === "confirm") {
|
||
body.subject_code = suggested.suggested_subject_code;
|
||
} else {
|
||
body.action = action;
|
||
}
|
||
const response = await apiJson(`/api/admin/intercompany/events/${row.dataset.ledgerEventId}/subject-decisions`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
}).catch(() => null);
|
||
if (!response) {
|
||
showToast("科目处理失败", "请刷新后重试");
|
||
return;
|
||
}
|
||
row.remove();
|
||
updateAuditCounts();
|
||
auditDialog.close();
|
||
event.currentTarget.reset();
|
||
syncExceptionNote();
|
||
showToast(
|
||
action === "confirm" ? "科目已确认并纳入计算" : action === "return" ? "已退回公司补充材料" : "已转为异常",
|
||
action === "confirm" ? `确认科目:${b44.subjectLabels[suggested.suggested_subject_code]} · 已写入修订链` : "当前记录暂不纳入余额计算",
|
||
);
|
||
await loadAdminAuditQueue();
|
||
return;
|
||
}
|
||
|
||
// B-44 manual record review: approve / return / exception.
|
||
if (row.dataset.recordKind === "manual-review") {
|
||
const actionMap = {
|
||
"确认并纳入计算": "approve_new",
|
||
"退回公司补充材料": "return",
|
||
"转为异常": "exception",
|
||
"转为异常待后续处理": "exception",
|
||
};
|
||
const action = actionMap[decision];
|
||
if (!action) {
|
||
showToast("请选择处理决定", "人工记录仅支持确认、退回或转异常");
|
||
return;
|
||
}
|
||
const response = await apiJson(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
action,
|
||
reason,
|
||
expected_decision_id: Number(row.dataset.expectedDecisionId),
|
||
request_key: `mr-dec-${row.dataset.recordId}-${Date.now().toString(36)}`,
|
||
}),
|
||
}).catch(() => null);
|
||
if (!response) {
|
||
showToast("审核结果提交失败", "请刷新后重试");
|
||
return;
|
||
}
|
||
const status = $("[data-audit-action]", row);
|
||
if (status) status.textContent = "查看记录";
|
||
row.dataset.resolved = "true";
|
||
const statusCell = row.children[5];
|
||
statusCell.innerHTML = `<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 (typeof document !== "undefined" && document.body) {
|
||
if (portal === "entry") {
|
||
initEntry();
|
||
} else {
|
||
initAuthGuard().then((allowed) => {
|
||
if (!allowed) return;
|
||
initShell();
|
||
initFlowTools();
|
||
if (portal === "admin") {
|
||
initAdmin();
|
||
} else {
|
||
initCompany();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
if (typeof module !== "undefined" && module.exports) {
|
||
module.exports = {
|
||
fmtAbsMoney,
|
||
fmtMoney,
|
||
eventIsNegative,
|
||
cycleTab,
|
||
drawerEscAction,
|
||
amountWithCurrency,
|
||
resultDirection,
|
||
accountCell,
|
||
};
|
||
}
|