HEL-177 公司端 HTML 版 formatWan 覆盖了 HEL-155 管理端纯数字版, 导致 textContent 赋值处显示原始 span 标签。将公司端重命名为 formatWanHtml 并替换调用,管理端三处恢复纯文本万元显示。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
4181 lines
200 KiB
JavaScript
4181 lines
200 KiB
JavaScript
const $ = (selector, scope = document) => scope.querySelector(selector);
|
||
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
||
|
||
const portal = document.body.dataset.portal || "entry";
|
||
const viewNames = portal === "admin"
|
||
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
|
||
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", transfers: "转账往来", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
|
||
|
||
const storageKeys = {
|
||
manual: "ledger-demo-manual-records",
|
||
};
|
||
|
||
const accountStatusLabels = {
|
||
pending: "待复核",
|
||
active: "已启用",
|
||
returned: "已退回",
|
||
disabled: "已停用",
|
||
};
|
||
|
||
const state = {
|
||
coverageGaps: [],
|
||
companyCoverageGaps: [],
|
||
companies: [],
|
||
currentView: portal === "admin" ? "dashboard" : "workspace",
|
||
selectedFile: null,
|
||
parseResult: null,
|
||
};
|
||
|
||
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||
|
||
function animateView(view, { initial = false } = {}) {
|
||
if (!view || motionQuery.matches || typeof view.animate !== "function") return;
|
||
if (!initial) {
|
||
view.getAnimations().forEach((animation) => animation.cancel());
|
||
view.animate(
|
||
[{ opacity: 0.84, transform: "translateY(5px)" }, { opacity: 1, transform: "translateY(0)" }],
|
||
{ duration: 180, easing: "cubic-bezier(.22,1,.36,1)" },
|
||
);
|
||
return;
|
||
}
|
||
const selectors = [
|
||
".page-head",
|
||
".stat-card",
|
||
".card",
|
||
".notice",
|
||
".list-row",
|
||
".filters",
|
||
];
|
||
const elements = [...new Set(selectors.flatMap((selector) => [...view.querySelectorAll(selector)]))];
|
||
|
||
elements.forEach((element, index) => {
|
||
element.getAnimations().forEach((animation) => animation.cancel());
|
||
const keyframes = [
|
||
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
|
||
{ opacity: 1, transform: "translateY(0)" },
|
||
];
|
||
element.animate(keyframes, {
|
||
duration: 440,
|
||
delay: Math.min(index * 38, 260),
|
||
easing: "cubic-bezier(.22,1,.36,1)",
|
||
fill: "both",
|
||
});
|
||
});
|
||
}
|
||
|
||
function initMotion() {
|
||
if (motionQuery.matches) return;
|
||
animateView($(".app-view.is-active"), { initial: true });
|
||
|
||
$$(".side-nav a", $("#sidebar") || document).forEach((item, index) => {
|
||
item.animate(
|
||
[{ opacity: 0, transform: "translateX(-8px)" }, { opacity: 1, transform: "translateX(0)" }],
|
||
{ duration: 360, delay: 90 + index * 28, easing: "cubic-bezier(.22,1,.36,1)", fill: "both" },
|
||
);
|
||
});
|
||
}
|
||
|
||
function readStoredRecords(key) {
|
||
try {
|
||
const value = JSON.parse(localStorage.getItem(key) || "[]");
|
||
return Array.isArray(value) ? value : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function writeStoredRecords(key, records) {
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify(records));
|
||
return true;
|
||
} catch {
|
||
showToast("本机演示数据保存失败", "请检查浏览器是否允许本地存储", "danger");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function recordStatus(status) {
|
||
if (["已启用", "已确认"].includes(status)) return { className: "success", label: status };
|
||
if (status === "已退回") return { className: "danger", label: status };
|
||
if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status };
|
||
return { className: "warning", label: status || "待复核" };
|
||
}
|
||
|
||
function accountStatusLabel(status) {
|
||
return accountStatusLabels[status] || "待复核";
|
||
}
|
||
|
||
function pillClass(statusClass) {
|
||
return { success: "pill-success", danger: "pill-danger", warning: "pill-warn", neutral: "pill-muted", info: "pill-info" }[statusClass] || "pill-muted";
|
||
}
|
||
|
||
function accountTail(masked) {
|
||
return String(masked || "").replace(/^\*+/, "");
|
||
}
|
||
|
||
function formatCurrency(value) {
|
||
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function showTableLoading(tbody, cols = 6) {
|
||
if (!tbody) return;
|
||
tbody.innerHTML = `<tr class="loading-row"><td colspan="${cols}">加载中…</td></tr>`;
|
||
}
|
||
|
||
function showTableError(tbody, cols = 6) {
|
||
if (!tbody) return;
|
||
tbody.innerHTML = `<tr class="loading-row"><td colspan="${cols}" style="color: var(--danger);">加载失败,请稍后重试</td></tr>`;
|
||
}
|
||
|
||
function showToast(title, detail = "", kind = "info") {
|
||
const region = $("#toastRegion");
|
||
if (!region) return;
|
||
const toast = document.createElement("div");
|
||
toast.className = `toast ${["success", "warn", "danger", "info"].includes(kind) ? kind : "info"}`;
|
||
toast.setAttribute("role", "status");
|
||
const dot = document.createElement("span");
|
||
dot.className = "t-dot";
|
||
const body = document.createElement("div");
|
||
body.className = "t-body";
|
||
const heading = document.createElement("div");
|
||
heading.className = "t-title";
|
||
heading.textContent = title;
|
||
body.append(heading);
|
||
if (detail) {
|
||
const description = document.createElement("div");
|
||
description.className = "t-detail";
|
||
description.textContent = detail;
|
||
body.append(description);
|
||
}
|
||
toast.append(dot, body);
|
||
region.append(toast);
|
||
window.setTimeout(() => {
|
||
toast.style.opacity = "0";
|
||
toast.style.transition = "opacity 0.2s ease";
|
||
window.setTimeout(() => toast.remove(), 200);
|
||
}, 3400);
|
||
}
|
||
|
||
function closeNavigation({ restoreFocus = false } = {}) {
|
||
const sidebar = $("#sidebar");
|
||
if (!sidebar) return;
|
||
const wasOpen = sidebar.classList.contains("is-open");
|
||
sidebar.classList.remove("is-open");
|
||
$$(".menu-button").forEach((button) => {
|
||
button.setAttribute("aria-expanded", "false");
|
||
button.setAttribute("aria-label", "打开导航");
|
||
});
|
||
if (restoreFocus && wasOpen) $(".menu-button")?.focus();
|
||
}
|
||
|
||
function showView(view) {
|
||
if (!viewNames[view]) return;
|
||
const navigationWasOpen = $("#sidebar")?.classList.contains("is-open");
|
||
state.currentView = view;
|
||
$$(".app-view").forEach((page) => page.classList.toggle("is-active", page.dataset.page === view));
|
||
$$(".side-nav a[data-view]").forEach((item) => {
|
||
const active = item.dataset.view === view;
|
||
item.classList.toggle("active", active);
|
||
if (active) item.setAttribute("aria-current", "page");
|
||
else item.removeAttribute("aria-current");
|
||
});
|
||
const title = $("#currentViewName");
|
||
if (title) {
|
||
if (view === "transfers" && state.transfersDetail?.company_name) {
|
||
title.textContent = `转账往来 / ${state.transfersDetail.company_name}`;
|
||
} else {
|
||
title.textContent = viewNames[view];
|
||
}
|
||
}
|
||
closeNavigation({ restoreFocus: navigationWasOpen });
|
||
const activeView = $(`.app-view[data-page="${view}"]`);
|
||
requestAnimationFrame(() => animateView(activeView));
|
||
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
|
||
if (portal === "company" && view === "transfers") {
|
||
if (state.transfersKeepDetail && state.transfersDetail?.company_id) {
|
||
showTransfersDetailLayer();
|
||
} else {
|
||
state.transfersDetail = null;
|
||
showTransfersOverviewLayer();
|
||
loadTransfersSummary();
|
||
}
|
||
}
|
||
state.transfersKeepDetail = false;
|
||
}
|
||
|
||
// 原型占位,非本司真待办/真断档数据:detailContent 仅用于演示总览/工作台事项
|
||
// 点开抽屉的静态文案,真实待办与断档来自审核中心 / 往来确认等接口数据。
|
||
const detailContent = {
|
||
"admin-gap": { tag: ["danger", "高风险"], title: "金牛置业 · 中行账户断档", desc: "中国银行尾号 8821 缺少 07-06 至 07-16 流水,已影响 7 月结账。", fields: [["公司", "金牛置业"], ["账户", "中国银行 · 尾号 8821"], ["缺口期间", "2026-07-06 — 07-16 · 11 天"], ["影响", "7 月结账 · 与金牛煤业 320 万往来无法归集"], ["当前状态", "阻断结账"]], tip: "先向金牛置业出纳发送补传提醒,补齐流水后到审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] },
|
||
"admin-unsubmitted": { tag: ["danger", "高风险"], title: "金牛农业 7 月未提交流水", desc: "全部账户 7 月流水均未提交,已发 2 次系统提醒。", fields: [["公司", "金牛农业"], ["账户", "全部账户"], ["已提醒", "2 次 · 最近一次 08-18"], ["当前状态", "未响应"]], tip: "可再次发送提醒,或在提醒管理里查看已发出的提醒记录。", action: ["去提醒管理", "reminders"] },
|
||
"admin-unilateral": { tag: ["warning", "中风险"], title: "金牛新能源 ↔ 金牛贸易 单边流水", desc: "3 笔单边流水合计 486 万元,待对方选择银行流水佐证。", fields: [["本方", "金牛新能源"], ["对方", "金牛贸易"], ["笔数 / 金额", "3 笔 · 486 万元"], ["当前状态", "待对方证据"]], tip: "提醒新能源侧在往来确认中选择对方银行流水佐证后提交。", action: ["去审核中心匹配", "audit"] },
|
||
"admin-subject": { tag: ["warning", "中风险"], title: "手工记录待确认往来科目", desc: "5 笔手工记录应收 / 其他应收待判定,涉及煤业、物流、贸易。", fields: [["待确认", "应收 / 其他应收"], ["涉及公司", "煤业、物流、贸易"], ["笔数", "5 笔"]], tip: "科目只按确定性规则建议,拿不准时选「其他应收」并注明依据。", action: ["去审核中心复核", "audit"] },
|
||
"task-match": { tag: ["danger", "阻断"], title: "确认 3 笔单边流水", desc: "选择对方银行流水作为证据后提交确认。", fields: [["笔数", "3 笔"], ["处理方式", "选择对方银行流水佐证"], ["关联", "7 月结账阻断项"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] },
|
||
"task-subject": { tag: ["danger", "阻断"], title: "确认 2 笔其他应收科目", desc: "核对后改判或维持原科目。", fields: [["笔数", "2 笔"], ["处理方式", "改判或维持原科目"], ["关联", "7 月结账阻断项"]], tip: "科目只按确定性规则建议,拿不准时选「其他应收」并注明依据。", action: ["去手工记录", "manual"] },
|
||
"task-upload": { tag: ["danger", "阻断"], title: "补传交行 7710 账户流水", desc: "账户已登记,待总行审核通过后即可导入。", fields: [["账户", "交通银行 · 尾号 7710"], ["状态", "待审核"], ["处理", "审核通过后上传 7 月流水"]], tip: "账户审核通过后,从交行网银导出 7 月流水直接上传。", action: ["去查看账户", "accounts"] },
|
||
"task-reconcile": { tag: ["muted", "一般"], title: "核对与金牛置业 320 万往来", desc: "置业 8821 账户 7 月流水断档,需人工核对。", fields: [["对方", "金牛置业"], ["金额", "320 万元"], ["原因", "置业 8821 账户流水断档"]], tip: "先核对双方流水日期与金额是否一致,再决定是否提交确认。", action: ["去流水管理", "flows"] },
|
||
};
|
||
|
||
function initDetailDrawer() {
|
||
const triggers = $$("[data-detail]");
|
||
if (!triggers.length) return;
|
||
const drawer = document.createElement("aside");
|
||
drawer.className = "drawer";
|
||
drawer.id = "detailDrawer";
|
||
drawer.setAttribute("aria-label", "事项详情");
|
||
drawer.innerHTML = `<div class="drawer-head"><div><span class="pill" id="detailTag"></span><h2 class="d-title" id="detailTitle"></h2><p class="d-desc" id="detailDesc"></p></div><button type="button" class="icon-button" data-close-detail aria-label="关闭详情" title="关闭详情"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div><div class="drawer-body"><dl class="kv" id="detailFields"></dl></div><div class="drawer-tip" id="detailTip"></div><div class="drawer-foot"><button type="button" class="btn" data-close-detail>关闭</button><button type="button" class="btn btn-primary" id="detailAction"></button></div>`;
|
||
document.body.append(drawer);
|
||
let lastTrigger = null;
|
||
|
||
function closeDrawer({ restoreFocus = true } = {}) {
|
||
drawer.classList.remove("is-open");
|
||
if (restoreFocus && lastTrigger) lastTrigger.focus();
|
||
}
|
||
|
||
function openDetail(id, trigger) {
|
||
const item = detailContent[id];
|
||
if (!item) return;
|
||
lastTrigger = trigger;
|
||
const tag = $("#detailTag", drawer);
|
||
tag.className = `pill ${pillClass(item.tag[0])}`;
|
||
tag.textContent = item.tag[1];
|
||
$("#detailTitle", drawer).textContent = item.title;
|
||
$("#detailDesc", drawer).textContent = item.desc;
|
||
const fields = $("#detailFields", drawer);
|
||
fields.replaceChildren(...item.fields.flatMap(([label, value]) => {
|
||
const dt = document.createElement("dt"); dt.textContent = label;
|
||
const dd = document.createElement("dd"); dd.textContent = value;
|
||
return [dt, dd];
|
||
}));
|
||
const tip = $("#detailTip", drawer);
|
||
tip.replaceChildren();
|
||
const tipHeading = document.createElement("strong"); tipHeading.textContent = "处理建议";
|
||
tip.append(tipHeading, document.createTextNode(item.tip));
|
||
const action = $("#detailAction", drawer);
|
||
action.textContent = item.action[0];
|
||
action.onclick = () => {
|
||
closeDrawer({ restoreFocus: false });
|
||
if (item.action[1] === "upload") $("[data-open-upload]")?.click();
|
||
else showView(item.action[1]);
|
||
};
|
||
drawer.classList.add("is-open");
|
||
$("[data-close-detail]", drawer).focus();
|
||
}
|
||
|
||
$$("[data-close-detail]", drawer).forEach((button) => button.addEventListener("click", () => closeDrawer()));
|
||
drawer.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
event.stopPropagation();
|
||
closeDrawer();
|
||
}
|
||
});
|
||
|
||
triggers.forEach((element) => {
|
||
element.addEventListener("click", (event) => {
|
||
const innerButton = event.target.closest("button");
|
||
if (innerButton && innerButton !== element) return;
|
||
openDetail(element.dataset.detail, element);
|
||
});
|
||
if (element.tagName !== "BUTTON") {
|
||
element.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
openDetail(element.dataset.detail, element);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function initEntry() {
|
||
const form = $("#loginForm");
|
||
if (!form) return;
|
||
if (!motionQuery.matches) {
|
||
[
|
||
[$(".entry-form-wrap"), 0],
|
||
[$(".entry-statement h1"), 60],
|
||
[$(".entry-statement p"), 120],
|
||
[$(".entry-figure"), 120],
|
||
].forEach(([element, delay]) => {
|
||
if (!element) return;
|
||
element.animate(
|
||
[{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "translateY(0)" }],
|
||
{ duration: 300, delay, easing: "cubic-bezier(.16,1,.3,1)", fill: "both" },
|
||
);
|
||
});
|
||
}
|
||
const roleInputs = $$('input[name="role"]', form);
|
||
const username = $('input[name="username"]', form);
|
||
const password = $('input[name="password"]', form);
|
||
const action = $("#loginAction");
|
||
const errorBox = $("#loginError");
|
||
const changeSection = $("#changePassword");
|
||
let pendingRole = null;
|
||
const submitButton = $('button[type="submit"]', form);
|
||
|
||
function setLoading(loading) {
|
||
submitButton.disabled = loading;
|
||
submitButton.classList.toggle("is-loading", loading);
|
||
$$("input, button", form).forEach((el) => {
|
||
if (el !== submitButton) el.disabled = loading;
|
||
});
|
||
if (loading) {
|
||
action.textContent = "登录中…";
|
||
} else if (pendingRole) {
|
||
action.textContent = "设置新密码并进入";
|
||
} else {
|
||
updateRole();
|
||
}
|
||
}
|
||
|
||
function showError(message) {
|
||
errorBox.textContent = message;
|
||
errorBox.hidden = false;
|
||
}
|
||
|
||
function updateRole() {
|
||
const role = $('input[name="role"]:checked', form).value;
|
||
action.textContent = role === "admin" ? "进入管理端" : "进入公司业务端";
|
||
}
|
||
|
||
roleInputs.forEach((input) => input.addEventListener("change", updateRole));
|
||
$("#togglePassword").addEventListener("click", (event) => {
|
||
const visible = password.type === "text";
|
||
password.type = visible ? "password" : "text";
|
||
event.currentTarget.setAttribute("aria-label", visible ? "显示密码" : "隐藏密码");
|
||
event.currentTarget.title = visible ? "显示密码" : "隐藏密码";
|
||
});
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
errorBox.hidden = true;
|
||
setLoading(true);
|
||
const role = pendingRole || $('input[name="role"]:checked', form).value;
|
||
|
||
if (pendingRole) {
|
||
const newPassword = $('input[name="new_password"]', form).value;
|
||
const confirmPassword = $('input[name="confirm_password"]', form).value;
|
||
if (newPassword !== confirmPassword) {
|
||
showError("两次输入的新密码不一致。");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
const changeResponse = await fetch("/api/password/change", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ old_password: password.value, new_password: newPassword }),
|
||
}).catch(() => null);
|
||
const changeResult = await changeResponse?.json().catch(() => ({}));
|
||
if (!changeResponse || !changeResponse.ok) {
|
||
showError(changeResult?.message || "修改密码失败,请稍后重试。");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
window.location.href = role === "admin" ? "admin.html" : "company.html";
|
||
return;
|
||
}
|
||
|
||
const response = await fetch("/api/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ username: username.value.trim(), password: password.value, portal: role }),
|
||
}).catch(() => null);
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
showError(result?.message || "登录服务暂时不可用,请稍后重试。");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
if (result.must_change_password) {
|
||
pendingRole = role;
|
||
changeSection.hidden = false;
|
||
setLoading(false);
|
||
$('input[name="new_password"]', form).focus();
|
||
return;
|
||
}
|
||
window.location.href = role === "admin" ? "admin.html" : "company.html";
|
||
});
|
||
}
|
||
|
||
async function initAuthGuard() {
|
||
if (portal === "entry") return true;
|
||
try {
|
||
const response = await fetch("/api/me");
|
||
if (response.status === 401) {
|
||
window.location.href = "index.html";
|
||
return false;
|
||
}
|
||
const me = await response.json();
|
||
if (!response.ok || me.role !== portal) {
|
||
window.location.href = "index.html";
|
||
return false;
|
||
}
|
||
state.me = me;
|
||
applyCompanyIdentity(me);
|
||
return true;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function applyCompanyIdentity(me) {
|
||
if (!me) return;
|
||
const row = $(".side-foot .user-row");
|
||
if (row) {
|
||
const name = me.username || (portal === "company" ? (me.company_name || "公司用户") : "系统管理员");
|
||
const avatar = $(".avatar", row);
|
||
if (avatar) avatar.textContent = name.slice(0, 1);
|
||
const nameEl = $(".user-name", row);
|
||
if (nameEl) nameEl.textContent = name;
|
||
const metaEl = $(".user-meta", row);
|
||
if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "管理员";
|
||
}
|
||
// The company portal always shows the session-bound company in page copy.
|
||
if (portal === "company" && me.company_name) {
|
||
$$(".company-identity").forEach((el) => { el.textContent = me.company_name; });
|
||
}
|
||
}
|
||
|
||
function initShell() {
|
||
$$(".topbar").forEach((bar) => {
|
||
if (bar.querySelector(".menu-button")) return;
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "icon-button menu-button";
|
||
button.setAttribute("aria-label", "打开导航");
|
||
button.setAttribute("aria-expanded", "false");
|
||
button.setAttribute("aria-controls", "sidebar");
|
||
button.title = "打开导航";
|
||
button.innerHTML = '<svg><use href="icons.svg#menu"></use></svg>';
|
||
bar.prepend(button);
|
||
});
|
||
|
||
$$(".menu-button").forEach((menuButton) => {
|
||
menuButton.addEventListener("click", () => {
|
||
const open = $("#sidebar").classList.toggle("is-open");
|
||
menuButton.setAttribute("aria-expanded", String(open));
|
||
menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航");
|
||
});
|
||
});
|
||
|
||
$$(".side-nav a").forEach((item) => {
|
||
const label = $("span", item)?.textContent.trim();
|
||
if (label) {
|
||
item.setAttribute("aria-label", label);
|
||
item.title = label;
|
||
}
|
||
});
|
||
$$("[data-view]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.view); }));
|
||
$$("[data-view-link]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.viewLink); }));
|
||
$$(".logout").forEach((control) => control.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
try {
|
||
await fetch("/api/logout", { method: "POST" });
|
||
} catch { /* 网络异常时仍然回到登录页 */ }
|
||
window.location.href = "index.html";
|
||
}));
|
||
$$("[data-metric-link]").forEach((card) => {
|
||
const activate = () => showView(card.dataset.metricLink);
|
||
card.addEventListener("click", activate);
|
||
card.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
activate();
|
||
}
|
||
});
|
||
});
|
||
$$("[data-metric-action=\"upload\"]").forEach((card) => {
|
||
const activate = () => $("[data-open-upload]")?.click();
|
||
card.addEventListener("click", activate);
|
||
card.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
activate();
|
||
}
|
||
});
|
||
});
|
||
// Delegated: company table rows are rendered from the API after init.
|
||
document.addEventListener("click", (event) => {
|
||
const toastButton = event.target.closest("[data-toast]");
|
||
if (toastButton) showToast(toastButton.dataset.toast);
|
||
});
|
||
$(".side-nav a[data-view].active")?.setAttribute("aria-current", "page");
|
||
|
||
document.addEventListener("click", (event) => {
|
||
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true });
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") closeNavigation({ restoreFocus: true });
|
||
});
|
||
|
||
initDetailDrawer();
|
||
initMotion();
|
||
}
|
||
|
||
function pairData(from, to, endDate) {
|
||
const companies = ["A公司", "B公司", "C公司", "D公司", "E公司", "F公司"];
|
||
const fromIndex = companies.indexOf(from) + 1;
|
||
const toIndex = companies.indexOf(to) + 1;
|
||
const low = Math.min(fromIndex, toIndex);
|
||
const high = Math.max(fromIndex, toIndex);
|
||
const seed = low * 13 + high * 7;
|
||
const canonical = {
|
||
receivable: 280 + seed * 18,
|
||
otherReceivable: 120 + seed * 9,
|
||
payable: 160 + ((seed * 11) % 720),
|
||
otherPayable: 80 + ((seed * 5) % 360),
|
||
};
|
||
const reversed = fromIndex > toIndex;
|
||
const receivable = reversed ? canonical.payable : canonical.receivable;
|
||
const otherReceivable = reversed ? canonical.otherPayable : canonical.otherReceivable;
|
||
const payable = reversed ? canonical.receivable : canonical.payable;
|
||
const otherPayable = reversed ? canonical.otherReceivable : canonical.otherPayable;
|
||
const debit = receivable + otherReceivable;
|
||
const credit = payable + otherPayable;
|
||
const opening = ((fromIndex + toIndex) % 3) * 60 * (reversed ? -1 : 1);
|
||
const final = opening + debit - credit;
|
||
const end = new Date(`${endDate}T12:00:00`);
|
||
const dateBefore = (days) => {
|
||
const date = new Date(end);
|
||
date.setDate(date.getDate() - days);
|
||
return date.toISOString().slice(0, 10);
|
||
};
|
||
return {
|
||
opening, debit, credit, final,
|
||
totals: { 应收: receivable, 其他应收: otherReceivable, 应付: payable, 其他应付: otherPayable },
|
||
rows: [
|
||
[dateBefore(2), "转出", "应收", "中信 · 5316", `${to} · 9481`, "往来款", "双边匹配", receivable],
|
||
[dateBefore(9), "转出", "其他应收", "建行 · 0845", `${to} · 2046`, "资金调拨", "双边匹配", otherReceivable],
|
||
[dateBefore(17), "转入", "应付", "中信 · 5316", `${to} · 9481`, "归还往来款", "双边匹配", payable],
|
||
[dateBefore(25), "转入", "其他应付", "农行 · 3650", `${to} · 6120`, "临时往来", "单边待核", otherPayable],
|
||
],
|
||
};
|
||
}
|
||
|
||
const pairSubjectOrder = ["应收", "其他应收", "应付", "其他应付"];
|
||
|
||
function setPair(from, to, endDate = "2026-07-31") {
|
||
$$('[data-pair-from]').forEach((item) => { item.textContent = from; });
|
||
$$('[data-pair-to]').forEach((item) => { item.textContent = to; });
|
||
$$("[data-pair-form]").forEach((form) => {
|
||
const fromSelect = $('[name="from"]', form);
|
||
const toSelect = $('[name="to"]', form);
|
||
if (fromSelect && [...fromSelect.options].some((option) => option.value === from)) fromSelect.value = from;
|
||
if (toSelect && [...toSelect.options].some((option) => option.value === to)) toSelect.value = to;
|
||
const endInput = $('[name="end"]', form);
|
||
if (endInput) endInput.value = endDate;
|
||
});
|
||
if (!$("#pairReport")) return;
|
||
const data = pairData(from, to, endDate);
|
||
const format = (value) => value.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
$("#pairPeriod").textContent = `统计口径 2026.01.01—${endDate}`;
|
||
$("#pairOpening").innerHTML = `${format(data.opening)}<span class="unit">万元</span>`;
|
||
$("#pairDebit").innerHTML = `${format(data.debit)}<span class="unit">万元</span>`;
|
||
$("#pairCredit").innerHTML = `${format(data.credit)}<span class="unit">万元</span>`;
|
||
$("#pairFinal").innerHTML = `${data.final >= 0 ? "应收" : "应付"} ${format(Math.abs(data.final))}<span class="unit">万元</span>`;
|
||
$("#pairReviewStatus").textContent = "含 1 笔待审核";
|
||
$('[data-subject-total="all"]').textContent = `${data.rows.length} 笔`;
|
||
Object.entries(data.totals).forEach(([subject, value]) => { $(`[data-subject-total="${subject}"]`)?.replaceChildren(document.createTextNode(format(value))); });
|
||
state.pairContext = { from, to, endDate };
|
||
state.pairRows = data.rows.map(([date, direction, subject, ownAccount, counterparty, summary, match, amount]) => ({ date, direction, subject, ownAccount, counterparty, summary, match, amount }));
|
||
renderPairRows();
|
||
}
|
||
|
||
function renderPairRows() {
|
||
const tbody = $("#pairTransactions");
|
||
if (!tbody) return;
|
||
const format = (value) => Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
tbody.innerHTML = state.pairRows.map((row, index) => {
|
||
const matched = row.match === "双边匹配";
|
||
return `<tr data-subject="${row.subject}" data-pair-idx="${index}">
|
||
<td class="num">${row.date}</td>
|
||
<td>${row.direction}</td>
|
||
<td>${row.subject}</td>
|
||
<td>${row.ownAccount}</td>
|
||
<td>${row.counterparty}</td>
|
||
<td class="wrap">${row.summary}</td>
|
||
<td><span class="pill ${matched ? "pill-success" : "pill-warn"}">${row.match}</span></td>
|
||
<td class="num-col ${row.direction === "转出" ? "amt-out" : "amt-in"}">${format(row.amount)}</td>
|
||
<td><button type="button" class="btn btn-sm" data-trace="${index}">穿透</button></td>
|
||
</tr>`;
|
||
}).join("");
|
||
}
|
||
|
||
function openTrace(index) {
|
||
const modal = $("#traceModal");
|
||
if (!modal) return;
|
||
const row = state.pairRows?.[index];
|
||
if (!row) return;
|
||
const ctx = state.pairContext || { from: "—", to: "—" };
|
||
$("#traceSub").textContent = `${ctx.from} ↔ ${ctx.to} · ${row.subject} · ${row.date} · ${row.direction} ${formatCurrency(row.amount)} 万元`;
|
||
$("#kvOwnTx").textContent = "—(演示数据,未关联银行流水)";
|
||
$("#kvOwnAcct").textContent = row.ownAccount;
|
||
$("#kvPeerCo").textContent = ctx.to;
|
||
$("#kvPeerAcct").textContent = row.counterparty;
|
||
$("#kvTime").textContent = row.date;
|
||
$("#kvAmt").textContent = `${formatCurrency(row.amount)} 万元`;
|
||
$("#kvBatch").textContent = "—";
|
||
const evidence = $("#kvEvidence");
|
||
if (row.match === "双边匹配") { evidence.textContent = "—"; evidence.style.color = ""; }
|
||
else { evidence.textContent = "待对方提供"; evidence.style.color = "var(--warn)"; }
|
||
modal.classList.add("open");
|
||
}
|
||
|
||
function initPairQueries() {
|
||
$$("[data-pair-form]").forEach((form) => {
|
||
$("[data-swap]", form)?.addEventListener("click", () => {
|
||
const from = $('[name="from"]', form);
|
||
const to = $('[name="to"]', form);
|
||
const previous = from.value;
|
||
from.value = to.value;
|
||
to.value = previous;
|
||
});
|
||
form.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const from = $('[name="from"]', form).value;
|
||
const to = $('[name="to"]', form).value;
|
||
const endDate = $('[name="end"]', form)?.value || "2026-07-31";
|
||
const error = $("#pairError");
|
||
if (from === to) {
|
||
if (error) { error.style.display = ""; }
|
||
showToast("请选择两个不同的公司", "同公司账户调拨不进入公司间往来查询", "warn");
|
||
return;
|
||
}
|
||
if (error) error.style.display = "none";
|
||
const notice = $("#pairNotice");
|
||
if (notice) notice.style.display = "none";
|
||
setPair(from, to, endDate);
|
||
showView("pair");
|
||
});
|
||
});
|
||
|
||
$$("[data-pair-link]").forEach((button) => button.addEventListener("click", () => {
|
||
const [from, to] = button.dataset.pairLink.split("|");
|
||
setPair(from, to);
|
||
showView("pair");
|
||
}));
|
||
|
||
$$("[data-subject-filter]").forEach((button) => button.addEventListener("click", () => {
|
||
const subject = button.dataset.subjectFilter;
|
||
$$("[data-subject-filter]").forEach((item) => {
|
||
const active = item === button;
|
||
item.classList.toggle("active", active);
|
||
item.setAttribute("aria-pressed", String(active));
|
||
});
|
||
$$("#pairTransactions tr").forEach((row) => { row.hidden = subject !== "all" && row.dataset.subject !== subject; });
|
||
}));
|
||
|
||
$("#pairTransactions")?.addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-trace]");
|
||
if (button) openTrace(Number(button.dataset.trace));
|
||
});
|
||
$("#traceClose")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open"));
|
||
$("#traceOk")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open"));
|
||
$("#traceModal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); });
|
||
}
|
||
|
||
function manualStatusMeta(status) {
|
||
if (status === "已确认") return { cls: "pill-success", label: "已通过" };
|
||
if (status === "已驳回") return { cls: "pill-danger", label: "已驳回" };
|
||
return { cls: "pill-info", label: "待审核" };
|
||
}
|
||
|
||
function renderCompanyManualRecords() {
|
||
const tbody = $("#manualRecordRows");
|
||
if (!tbody) return;
|
||
tbody.replaceChildren();
|
||
const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司");
|
||
[...records].reverse().forEach((record) => {
|
||
const row = document.createElement("tr");
|
||
row.dataset.storedRecord = record.id;
|
||
|
||
const date = document.createElement("td"); date.className = "num"; date.textContent = record.transactionDate || "—";
|
||
|
||
const direction = document.createElement("td"); direction.textContent = record.direction || "—";
|
||
|
||
const counterparty = document.createElement("td");
|
||
const counterpartyName = document.createElement("span"); counterpartyName.className = "cell-main"; counterpartyName.textContent = record.counterparty;
|
||
const counterpartyType = document.createElement("span"); counterpartyType.className = "cell-sub"; counterpartyType.textContent = record.counterpartyType || "";
|
||
counterparty.append(counterpartyName, counterpartyType);
|
||
|
||
const subject = document.createElement("td"); subject.innerHTML = `<span class="tag">${record.subject || "—"}</span>`;
|
||
|
||
const isIn = record.direction === "收款";
|
||
const amount = document.createElement("td");
|
||
amount.className = `num-col ${isIn ? "amt-in" : "amt-out"}`;
|
||
amount.textContent = `${isIn ? "+" : "-"}¥ ${formatCurrency(record.amount)}`;
|
||
|
||
const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = record.summary || "—";
|
||
|
||
const statusMeta = manualStatusMeta(record.status);
|
||
const statusCell = document.createElement("td");
|
||
statusCell.innerHTML = `<span class="pill ${statusMeta.cls}">${statusMeta.label}</span>`;
|
||
|
||
const action = document.createElement("td");
|
||
if (record.status === "待管理复核") {
|
||
action.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-action="withdraw">撤回</button>';
|
||
} else {
|
||
action.innerHTML = '<span class="muted">—</span>';
|
||
}
|
||
|
||
row.append(date, direction, counterparty, subject, amount, summary, statusCell, action);
|
||
tbody.append(row);
|
||
});
|
||
updateManualCounts();
|
||
}
|
||
|
||
function updateManualCounts() {
|
||
const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司");
|
||
const pending = records.filter((record) => record.status === "待管理复核").length;
|
||
const pendingEl = $("#manualPendingStatus");
|
||
if (pendingEl) pendingEl.textContent = pending;
|
||
const foot = $("#manualFoot");
|
||
if (foot) foot.textContent = `共 ${records.length} 条 · 待复核 ${pending} 条`;
|
||
const empty = $("#manualEmpty");
|
||
if (empty) empty.hidden = records.length > 0;
|
||
}
|
||
|
||
function fillAccountSelects(accounts) {
|
||
const usable = accounts.filter((account) => account.usable);
|
||
[$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => {
|
||
if (!select) return;
|
||
const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账");
|
||
select.replaceChildren(...kept);
|
||
usable.forEach((account) => {
|
||
const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`;
|
||
const option = new Option(value, value);
|
||
option.dataset.accountId = account.id;
|
||
select.add(option);
|
||
});
|
||
});
|
||
}
|
||
|
||
function companyAccountMeta(status) {
|
||
if (status === "active") return { status: { cls: "pill-success", label: "启用" }, audit: { cls: "pill-success", label: "已审核" } };
|
||
if (status === "returned") return { status: { cls: "pill-danger", label: "已退回" }, audit: { cls: "pill-danger", label: "已退回" } };
|
||
if (status === "disabled") return { status: { cls: "pill-muted", label: "停用" }, audit: { cls: "pill-success", label: "已审核" } };
|
||
return { status: { cls: "pill-info", label: "待启用" }, audit: { cls: "pill-info", label: "待审核" } };
|
||
}
|
||
|
||
function renderCompanyAccounts(accounts) {
|
||
state.accounts = accounts;
|
||
const tbody = $("#account-tbody");
|
||
if (tbody) {
|
||
tbody.replaceChildren(...accounts.map((account) => {
|
||
const row = document.createElement("tr");
|
||
row.dataset.accountId = account.id;
|
||
|
||
const identity = document.createElement("td");
|
||
const name = document.createElement("span"); name.className = "cell-main"; name.textContent = account.bank_name;
|
||
const tail = document.createElement("span"); tail.className = "cell-sub"; tail.textContent = `尾号 ${accountTail(account.account_number_masked)}`;
|
||
identity.append(name, tail);
|
||
|
||
const type = document.createElement("td"); type.innerHTML = `<span class="tag">${account.account_type || "—"}</span>`;
|
||
|
||
const meta = companyAccountMeta(account.status);
|
||
const statusCell = document.createElement("td"); statusCell.innerHTML = `<span class="pill ${meta.status.cls}">${meta.status.label}</span>`;
|
||
|
||
const coverage = document.createElement("td");
|
||
coverage.className = "num muted";
|
||
coverage.textContent = account.usable ? "尚未上传" : "—";
|
||
|
||
const auditCell = document.createElement("td"); auditCell.innerHTML = `<span class="pill ${meta.audit.cls}">${meta.audit.label}</span>`;
|
||
|
||
const action = document.createElement("td"); action.innerHTML = '<button type="button" class="btn btn-sm" data-account-view>查看明细</button>';
|
||
|
||
row.append(identity, type, statusCell, coverage, auditCell, action);
|
||
return row;
|
||
}));
|
||
}
|
||
const count = $("#account-count");
|
||
if (count) count.textContent = accounts.length;
|
||
const foot = $("#account-foot");
|
||
if (foot) foot.textContent = `共 ${accounts.length} 个账户`;
|
||
const empty = $("#account-empty");
|
||
if (empty) empty.hidden = accounts.length > 0;
|
||
fillAccountSelects(accounts);
|
||
}
|
||
|
||
async function loadCompanyAccounts() {
|
||
const tbody = $("#account-tbody");
|
||
showTableLoading(tbody, 6);
|
||
const response = await fetch("/api/company/accounts").catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
if (!response?.ok) {
|
||
showTableError(tbody, 6);
|
||
return;
|
||
}
|
||
const result = await response.json().catch(() => null);
|
||
if (result?.accounts) renderCompanyAccounts(result.accounts);
|
||
else showTableError(tbody, 6);
|
||
}
|
||
|
||
const manualSubjectLabels = {
|
||
receivable: "应收",
|
||
payable: "应付",
|
||
other_receivable: "其他应收",
|
||
other_payable: "其他应付",
|
||
};
|
||
|
||
const manualDirectionLabels = {
|
||
incoming: "收入",
|
||
outgoing: "支出",
|
||
};
|
||
|
||
function appendAdminReviewRow(record, kind) {
|
||
const tbody = $("#auditRows");
|
||
if (!tbody) return;
|
||
const isAccount = kind === "account";
|
||
const isManual = kind === "manual";
|
||
const statusLabel = isAccount
|
||
? accountStatusLabel(record.status)
|
||
: (record.state === "pending" ? "待管理复核" : (record.state || "待复核"));
|
||
const row = document.createElement("tr");
|
||
row.dataset.storedReview = String(record.id);
|
||
row.dataset.recordId = String(record.id);
|
||
row.dataset.recordKind = kind;
|
||
if (isAccount) row.dataset.accountId = String(record.id);
|
||
if (isManual) {
|
||
row.dataset.decisionId = String(record.decision_id || "");
|
||
row.dataset.requestedSubject = record.requested_subject || "";
|
||
}
|
||
row.dataset.auditType = isAccount ? "账户" : "手工";
|
||
row.dataset.company = isAccount
|
||
? (record.company_name || "")
|
||
: (record.company_name || record.company || "");
|
||
row.dataset.evidence = isAccount
|
||
? "公司提交资料、开户行、账号、账户类型与启用日期"
|
||
: "公司手工记录、关联银行流水号、证明附件与提交说明";
|
||
if (isAccount) {
|
||
if (record.status !== "pending") row.dataset.resolved = "true";
|
||
row.dataset.accountStatus = record.status;
|
||
} else if (record.state && record.state !== "pending") {
|
||
row.dataset.resolved = "true";
|
||
}
|
||
|
||
const riskCell = document.createElement("td");
|
||
riskCell.innerHTML = '<span class="pill pill-warn">中</span>';
|
||
|
||
const identityCell = document.createElement("td");
|
||
const identity = document.createElement("span"); identity.className = "cell-main";
|
||
const detail = document.createElement("span"); detail.className = "cell-sub";
|
||
if (isAccount) {
|
||
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
|
||
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
|
||
} else {
|
||
const subject = manualSubjectLabels[record.requested_subject] || record.requested_subject || "—";
|
||
const direction = manualDirectionLabels[record.direction] || record.direction || "";
|
||
const counterparty = record.counterparty_company_name || record.counterparty || "—";
|
||
identity.textContent = `${row.dataset.company} · 手工单 #${record.id}`;
|
||
detail.textContent = `${direction} ${formatCurrency(record.amount)} 元 · ${counterparty} · ${subject}`;
|
||
}
|
||
identityCell.append(identity, detail);
|
||
|
||
const typeCell = document.createElement("td");
|
||
typeCell.textContent = isAccount ? "账户登记" : "手工记录";
|
||
|
||
const periodCell = document.createElement("td");
|
||
if (isAccount) {
|
||
periodCell.textContent = record.effective_from || "待审核确定";
|
||
} else {
|
||
periodCell.className = "num";
|
||
periodCell.textContent = String(record.occurred_at || record.transactionDate || "").slice(0, 10) || "—";
|
||
}
|
||
|
||
const impactCell = document.createElement("td");
|
||
impactCell.className = "wrap";
|
||
impactCell.textContent = isAccount
|
||
? "账户识别与流水上传"
|
||
: `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`;
|
||
|
||
const statusCell = document.createElement("td");
|
||
const status = recordStatus(statusLabel);
|
||
statusCell.innerHTML = `<span class="pill ${pillClass(status.className)}">${status.label}</span>`;
|
||
|
||
const actionCell = document.createElement("td");
|
||
if (row.dataset.resolved) {
|
||
const isActiveAccount = isAccount && record.status === "active";
|
||
if (isActiveAccount) {
|
||
actionCell.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-audit-action="disable">停用</button>';
|
||
} else {
|
||
const resolvedLabel = isAccount
|
||
? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过"
|
||
: (statusLabel === "已确认" || record.state === "approved" ? "已通过" : "已驳回");
|
||
actionCell.innerHTML = `<span class="meta">${resolvedLabel} · 系统管理员</span>`;
|
||
}
|
||
} else {
|
||
actionCell.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div>';
|
||
}
|
||
|
||
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
|
||
tbody.append(row);
|
||
}
|
||
|
||
function appendMatchExceptionRow(item) {
|
||
const tbody = $("#auditRows");
|
||
if (!tbody) return;
|
||
const payer = item.payer_company_name || "—";
|
||
const payee = item.payee_company_name || "—";
|
||
const companyLabel = payer !== "—" ? payer : payee;
|
||
const row = document.createElement("tr");
|
||
row.dataset.storedReview = `match-${item.event_id}`;
|
||
row.dataset.recordId = String(item.event_id);
|
||
row.dataset.recordKind = "match";
|
||
row.dataset.eventId = String(item.event_id);
|
||
row.dataset.revision = String(item.revision ?? "");
|
||
row.dataset.auditType = "单边";
|
||
row.dataset.company = companyLabel;
|
||
row.dataset.evidence = "匹配异常事件、观察流水、参与方与历史决定";
|
||
row.dataset.classification = item.classification || "";
|
||
|
||
const riskCell = document.createElement("td");
|
||
riskCell.innerHTML = '<span class="pill pill-danger">高</span>';
|
||
|
||
const identityCell = document.createElement("td");
|
||
const identity = document.createElement("span"); identity.className = "cell-main";
|
||
const detail = document.createElement("span"); detail.className = "cell-sub";
|
||
identity.textContent = `${payer} ↔ ${payee} · 事件 #${item.event_id}`;
|
||
detail.textContent = `${item.classification || item.status || "unresolved"} · ${formatCurrency(item.amount)} ${item.currency || "CNY"} · 证据 ${item.evidence_count ?? 0} 条`;
|
||
identityCell.append(identity, detail);
|
||
|
||
const typeCell = document.createElement("td");
|
||
typeCell.textContent = "单边匹配";
|
||
|
||
const periodCell = document.createElement("td");
|
||
periodCell.className = "num";
|
||
periodCell.textContent = String(item.effective_at || "").slice(0, 10) || "—";
|
||
|
||
const impactCell = document.createElement("td");
|
||
impactCell.className = "wrap";
|
||
impactCell.textContent = `匹配异常 ${(Number(item.amount) / 10000).toFixed(2)} 万元 · 阻断往来归集`;
|
||
|
||
const statusCell = document.createElement("td");
|
||
statusCell.innerHTML = '<span class="pill pill-danger">待处理</span>';
|
||
|
||
const actionCell = document.createElement("td");
|
||
actionCell.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">关闭异常</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">退回重匹配</button></div>';
|
||
|
||
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
|
||
tbody.append(row);
|
||
}
|
||
|
||
async function loadAdminAuditQueue() {
|
||
const tbody = $("#auditRows");
|
||
if (!tbody) return;
|
||
$$('[data-stored-review]', tbody).forEach((row) => row.remove());
|
||
showTableLoading(tbody, 7);
|
||
|
||
const [accountsRes, manualsRes, exceptionsRes] = await Promise.all([
|
||
fetch("/api/admin/accounts?status=pending").catch(() => null),
|
||
fetch("/api/admin/manual-records?state=pending").catch(() => null),
|
||
fetch("/api/admin/match-exceptions").catch(() => null),
|
||
]);
|
||
|
||
if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) {
|
||
showTableError(tbody, 7);
|
||
showToast("审核队列加载失败", "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
|
||
const [accountsPayload, manualsPayload, exceptionsPayload] = await Promise.all([
|
||
accountsRes.json().catch(() => null),
|
||
manualsRes.json().catch(() => null),
|
||
exceptionsRes.json().catch(() => null),
|
||
]);
|
||
|
||
tbody.replaceChildren();
|
||
(accountsPayload?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
||
(manualsPayload?.records || []).forEach((record) => appendAdminReviewRow(record, "manual"));
|
||
(exceptionsPayload?.exceptions || []).forEach((item) => appendMatchExceptionRow(item));
|
||
|
||
updateAuditCounts();
|
||
updatePendingAccountNotice();
|
||
await refreshAuditCountsFromApi();
|
||
// 列表条数必须与后端口径一致:不一致时以真实列表为准覆盖标题/角标,避免再出现「数字 3 / 列表 1」。
|
||
const unresolved = $$("#auditRows tr").filter((row) => row.dataset.resolved !== "true");
|
||
if (!state.dashAudit || Number(state.dashAudit.total) !== unresolved.length) {
|
||
const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length;
|
||
const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length;
|
||
const low = Math.max(0, unresolved.length - high - medium);
|
||
applyAuditCounts({ total: unresolved.length, high, medium, low });
|
||
}
|
||
}
|
||
|
||
function renderStoredAdminReviews() {
|
||
if (!$("#auditRows")) return;
|
||
// 完整队列:待复核账户 + 待审手工单 + 匹配异常,三处口径同源。
|
||
loadAdminAuditQueue();
|
||
}
|
||
|
||
function updateAuditCounts() {
|
||
const rows = $$("#auditRows tr").filter((row) => !row.classList.contains("loading-row"));
|
||
const unresolved = rows.filter((row) => row.dataset.resolved !== "true");
|
||
$$('[data-audit-filter]').forEach((button) => {
|
||
const type = button.dataset.auditFilter;
|
||
const count = unresolved.filter((row) => type === "all" || row.dataset.auditType === type).length;
|
||
const badge = $(".tab-count", button);
|
||
if (badge) badge.textContent = count;
|
||
});
|
||
const foot = $("#auditFoot");
|
||
if (foot) foot.textContent = `共 ${unresolved.length} 项 · 待审核 ${unresolved.length} 项`;
|
||
// 角标 / 首页待审核卡 / 审核中心标题数:优先后端口径;列表加载完成后由 loadAdminAuditQueue 再对齐。
|
||
if (!state.dashAuditFromApi) {
|
||
const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length;
|
||
const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length;
|
||
const low = Math.max(0, unresolved.length - high - medium);
|
||
applyAuditCounts({ total: unresolved.length, high, medium, low });
|
||
} else {
|
||
const pending = $("#pending-count");
|
||
if (pending) pending.textContent = String(unresolved.length);
|
||
}
|
||
}
|
||
|
||
function applyAuditCounts(audit, { fromApi = false } = {}) {
|
||
if (!audit) return;
|
||
const total = Number(audit.total) || 0;
|
||
const high = Number(audit.high) || 0;
|
||
const medium = Number(audit.medium) || 0;
|
||
const low = Number(audit.low) || 0;
|
||
state.dashAudit = { total, high, medium, low };
|
||
if (fromApi) state.dashAuditFromApi = true;
|
||
|
||
const totalEl = $("#dashAuditTotal");
|
||
const footEl = $("#dashAuditFoot");
|
||
if (totalEl) totalEl.innerHTML = `${total}<span class="unit">项</span>`;
|
||
if (footEl) footEl.textContent = `高 ${high} 项 · 中 ${medium} 项 · 其余 ${low} 项低风险`;
|
||
|
||
const badge = $("#auditNavBadge") || $('.side-nav a[data-view="audit"] .nav-badge');
|
||
if (badge) {
|
||
badge.textContent = String(total);
|
||
badge.style.display = total > 0 ? "" : "none";
|
||
}
|
||
const pending = $("#pending-count");
|
||
if (pending) pending.textContent = String(total);
|
||
}
|
||
|
||
async function refreshAuditCountsFromApi() {
|
||
const from = await resolveDashStartDate();
|
||
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
|
||
if (!response?.ok) return null;
|
||
const data = await response.json().catch(() => null);
|
||
if (!data || data.status !== "ok" || !data.audit) return null;
|
||
applyAuditCounts(data.audit, { fromApi: true });
|
||
return data.audit;
|
||
}
|
||
|
||
function companyStatusBadge(status) {
|
||
if (status === "preparing") return { className: "neutral", label: "筹备中" };
|
||
if (status === "disabled") return { className: "danger", label: "已停用" };
|
||
return { className: "success", label: "正常" };
|
||
}
|
||
|
||
function renderAdminCompanyTable(companies) {
|
||
const tbody = $("#companyTable tbody");
|
||
if (!tbody) return;
|
||
state.companies = companies;
|
||
tbody.replaceChildren(...companies.map((company) => {
|
||
const row = document.createElement("tr");
|
||
row.dataset.companyId = company.id;
|
||
const nameCell = document.createElement("td");
|
||
const name = document.createElement("span"); name.className = "cell-main"; name.textContent = company.name;
|
||
const code = document.createElement("span"); code.className = "cell-sub"; code.textContent = company.credit_code || "统一社会信用代码待补充";
|
||
nameCell.append(name, code);
|
||
const accounts = document.createElement("td"); accounts.className = "num-col"; accounts.textContent = `${company.account_count ?? 0}`;
|
||
const usernames = document.createElement("td"); usernames.className = "num"; usernames.textContent = company.usernames || "未创建";
|
||
const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定";
|
||
const statusCell = document.createElement("td");
|
||
const badge = companyStatusBadge(company.status);
|
||
statusCell.innerHTML = `<span class="pill ${pillClass(badge.className)}">${badge.label}</span>`;
|
||
const actionCell = document.createElement("td");
|
||
actionCell.innerHTML = `<button type="button" class="btn btn-sm" data-company-view="${company.id}">查看</button>`;
|
||
row.append(nameCell, accounts, usernames, cashier, statusCell, actionCell);
|
||
return row;
|
||
}));
|
||
const foot = $("#companyFoot");
|
||
if (foot) foot.textContent = `共 ${companies.length} 家公司`;
|
||
}
|
||
|
||
function updatePendingAccountNotice() {
|
||
const rows = $$('#auditRows tr[data-record-kind="account"]');
|
||
const pending = rows.filter((r) => r.dataset.resolved !== "true");
|
||
const notice = $("#notice-pending-account");
|
||
if (!notice) return;
|
||
if (pending.length) {
|
||
notice.style.display = "";
|
||
$("#pending-account-count").textContent = pending.length;
|
||
const names = [...new Set(pending.map((r) => r.dataset.company))].join("、");
|
||
$("#pending-account-body").textContent = (names || "成员公司") + " 提交了新银行账户登记,等待审核通过后纳入账期流水归集范围。";
|
||
} else {
|
||
notice.style.display = "none";
|
||
}
|
||
}
|
||
|
||
function setSelectOptions(select, names, { keepFirst = false } = {}) {
|
||
if (!select || !names.length) return;
|
||
const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : [];
|
||
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
|
||
}
|
||
|
||
function fillCompanySelects(names) {
|
||
// Every company picker is driven by master data: a newly created company
|
||
// appears in pair queries, audit filters, flow filters and reminders
|
||
// without any code change.
|
||
$$("[data-pair-form]").forEach((form) => {
|
||
setSelectOptions($('[name="from"]', form), names);
|
||
setSelectOptions($('[name="to"]', form), names);
|
||
const toSelect = $('[name="to"]', form);
|
||
if (toSelect && names.length > 1) toSelect.value = names[1];
|
||
});
|
||
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
|
||
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
|
||
const reminderSelect = $("#reminder-company");
|
||
if (reminderSelect) {
|
||
const kept = reminderSelect.querySelector('option[value=""]') ? [reminderSelect.options[0].cloneNode(true)] : [];
|
||
const companies = state.companies || [];
|
||
reminderSelect.replaceChildren(...kept, ...companies.map((company) => new Option(company.name, company.id)));
|
||
}
|
||
setSelectOptions($('#openingDialog [name="from"]'), names);
|
||
setSelectOptions($('#openingDialog [name="to"]'), names);
|
||
setSelectOptions($("#ql-self"), names);
|
||
setSelectOptions($("#ql-peer"), names);
|
||
if ($("#ql-peer") && names.length > 1) $("#ql-peer").value = names[0];
|
||
if ($("#ql-self") && names.length > 1) $("#ql-self").value = names[names.length - 1];
|
||
}
|
||
|
||
|
||
function openingStatusPill(status) {
|
||
if (status === "confirmed") return { cls: "pill-success", label: "已确认" };
|
||
if (status === "void") return { cls: "pill-danger", label: "已作废" };
|
||
if (status === "superseded") return { cls: "pill-muted", label: "已替代" };
|
||
return { cls: "pill-warn", label: "待确认" };
|
||
}
|
||
|
||
function formatMoneyYuan(value) {
|
||
const num = Number(value);
|
||
if (Number.isNaN(num)) return "—";
|
||
return num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function humanizeChangeValue(value) {
|
||
if (value == null) return "—";
|
||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||
return String(value);
|
||
}
|
||
if (typeof value === "object") {
|
||
const entries = Object.entries(value);
|
||
if (!entries.length) return "—";
|
||
return entries.map(([k, v]) => `${k}=${v == null ? "空" : v}`).join(",");
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
function openModal(id) { $("#" + id)?.classList.add("open"); }
|
||
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
|
||
|
||
function askReason({ title, subtitle, confirmLabel } = {}) {
|
||
return new Promise((resolve) => {
|
||
const dialog = $("#reasonDialog");
|
||
const form = $("#reasonForm");
|
||
const input = $("#reasonInput");
|
||
if (!dialog || !form || !input) {
|
||
resolve(null);
|
||
return;
|
||
}
|
||
const titleEl = $("#reasonDialogTitle");
|
||
const subEl = $("#reasonDialogSub");
|
||
const submitBtn = $("#reasonSubmit");
|
||
if (titleEl && title) titleEl.textContent = title;
|
||
if (subEl && subtitle) subEl.textContent = subtitle;
|
||
if (submitBtn && confirmLabel) submitBtn.textContent = confirmLabel;
|
||
input.value = "";
|
||
const cleanup = () => {
|
||
form.removeEventListener("submit", onSubmit);
|
||
$$("[data-close-reason]").forEach((btn) => btn.removeEventListener("click", onCancel));
|
||
closeModal("reasonDialog");
|
||
};
|
||
const onCancel = () => { cleanup(); resolve(null); };
|
||
const onSubmit = (event) => {
|
||
event.preventDefault();
|
||
const reason = String(input.value || "").trim();
|
||
if (reason.length < 2) {
|
||
showToast("原因过短", "请至少填写 2 个字", "warn");
|
||
return;
|
||
}
|
||
cleanup();
|
||
resolve(reason);
|
||
};
|
||
form.addEventListener("submit", onSubmit);
|
||
$$("[data-close-reason]").forEach((btn) => btn.addEventListener("click", onCancel));
|
||
openModal("reasonDialog");
|
||
input.focus();
|
||
});
|
||
}
|
||
|
||
async function loadCalculationSettings() {
|
||
const response = await fetch("/api/admin/settings/calculation-start").catch(() => null);
|
||
if (!response?.ok) return null;
|
||
const data = await response.json().catch(() => null);
|
||
if (!data) return null;
|
||
const startInput = $("#cs-start");
|
||
const hint = $("#cs-start-hint");
|
||
const locked = $("#cs-start-locked");
|
||
if (startInput) {
|
||
startInput.value = data.calculation_start_date || "";
|
||
startInput.disabled = Boolean(data.locked);
|
||
startInput.dataset.locked = data.locked ? "1" : "0";
|
||
startInput.dataset.current = data.calculation_start_date || "";
|
||
}
|
||
if (hint) {
|
||
if (!data.calculation_start_date) {
|
||
hint.textContent = "未设置起算日,系统暂按期间净变动口径显示";
|
||
hint.style.color = "var(--warn)";
|
||
} else {
|
||
hint.textContent = "期初余额以此日前一日的期末数为准";
|
||
hint.style.color = "";
|
||
}
|
||
}
|
||
if (locked) locked.style.display = data.locked ? "" : "none";
|
||
const subtitle = $("#openingSub");
|
||
if (subtitle && data.calculation_start_date) {
|
||
subtitle.textContent = `${data.calculation_start_date} 起算的公司间往来期初数`;
|
||
}
|
||
const summary = $("#openingSummary");
|
||
if (summary && data.summary) {
|
||
summary.style.display = "";
|
||
summary.className = "notice info";
|
||
summary.replaceChildren();
|
||
const wrap = document.createElement("div");
|
||
const title = document.createElement("div");
|
||
title.className = "n-title";
|
||
title.textContent = `共 ${data.summary.company_count} 家公司 · 已确认 ${data.summary.confirmed_pair_count} 对公司对期初`;
|
||
wrap.append(title);
|
||
summary.append(wrap);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function appendEmptyRow(tbody, cols, text) {
|
||
tbody.replaceChildren();
|
||
const tr = document.createElement("tr");
|
||
const td = document.createElement("td");
|
||
td.colSpan = cols;
|
||
td.className = "empty";
|
||
td.textContent = text;
|
||
tr.append(td);
|
||
tbody.append(tr);
|
||
}
|
||
|
||
async function loadOpeningBalances() {
|
||
const tbody = $("#openingRows");
|
||
if (!tbody) return;
|
||
const response = await fetch("/api/admin/opening-balances").catch(() => null);
|
||
if (!response?.ok) {
|
||
appendEmptyRow(tbody, 8, "加载失败");
|
||
return;
|
||
}
|
||
const data = await response.json().catch(() => null);
|
||
const items = data?.items || [];
|
||
if (!items.length) {
|
||
appendEmptyRow(tbody, 8, "暂无期初记录");
|
||
return;
|
||
}
|
||
tbody.replaceChildren();
|
||
items.forEach((item) => {
|
||
const pill = openingStatusPill(item.status);
|
||
const amount = Number(item.amount);
|
||
const direction = amount >= 0 ? "应收" : "应付";
|
||
const tr = document.createElement("tr");
|
||
if (item.status === "void") tr.style.textDecoration = "line-through";
|
||
tr.dataset.openingId = String(item.id);
|
||
const cells = [
|
||
["td", "cell-main", item.company_low_name || "—"],
|
||
["td", "", item.company_high_name || "—"],
|
||
["td", "", direction],
|
||
["td", "num-col", formatMoneyYuan(Math.abs(amount))],
|
||
["td", "", item.actor_username || "—"],
|
||
["td", "num", (item.created_at || "").slice(0, 10)],
|
||
];
|
||
cells.forEach(([tag, cls, text]) => {
|
||
const td = document.createElement(tag);
|
||
if (cls) td.className = cls;
|
||
if (cls === "" && text === direction) {
|
||
td.style.color = amount >= 0 ? "var(--success)" : "var(--danger)";
|
||
}
|
||
td.textContent = text;
|
||
tr.append(td);
|
||
});
|
||
const statusTd = document.createElement("td");
|
||
const span = document.createElement("span");
|
||
span.className = `pill ${pill.cls}`;
|
||
span.textContent = pill.label;
|
||
statusTd.append(span);
|
||
tr.append(statusTd);
|
||
const actionTd = document.createElement("td");
|
||
if (item.status === "draft") {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn btn-sm";
|
||
btn.dataset.confirmOpening = String(item.id);
|
||
btn.textContent = "确认";
|
||
actionTd.append(btn);
|
||
} else if (item.status === "confirmed") {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn btn-sm btn-ghost btn-danger";
|
||
btn.dataset.voidOpening = String(item.id);
|
||
btn.textContent = "作废";
|
||
actionTd.append(btn);
|
||
}
|
||
tr.append(actionTd);
|
||
tbody.append(tr);
|
||
});
|
||
}
|
||
|
||
async function loadCalculationChanges() {
|
||
const tbody = $("#calculationChangeRows");
|
||
if (!tbody) return;
|
||
const response = await fetch("/api/admin/calculation-changes").catch(() => null);
|
||
if (!response?.ok) return;
|
||
const data = await response.json().catch(() => null);
|
||
const items = data?.items || [];
|
||
if (!items.length) {
|
||
appendEmptyRow(tbody, 5, "暂无变更记录");
|
||
return;
|
||
}
|
||
tbody.replaceChildren();
|
||
items.forEach((item) => {
|
||
const tr = document.createElement("tr");
|
||
const values = [
|
||
(item.created_at || "").replace("T", " ").slice(0, 16),
|
||
item.actor_username || "—",
|
||
item.target || "—",
|
||
`${humanizeChangeValue(item.before)} → ${humanizeChangeValue(item.after)}`,
|
||
item.reason || "—",
|
||
];
|
||
values.forEach((text, index) => {
|
||
const td = document.createElement("td");
|
||
if (index === 0) td.className = "num";
|
||
if (index >= 3) td.className = "wrap";
|
||
td.textContent = text;
|
||
tr.append(td);
|
||
});
|
||
tbody.append(tr);
|
||
});
|
||
}
|
||
|
||
function appendAuditGapRows(gaps) {
|
||
const tbody = $("#auditRows");
|
||
if (!tbody) return;
|
||
$$('#auditRows tr[data-audit-type="断档"]').forEach((row) => row.remove());
|
||
(gaps || []).forEach((gap) => {
|
||
const tr = document.createElement("tr");
|
||
tr.dataset.auditType = "断档";
|
||
tr.dataset.gapId = String(gap.id || "");
|
||
tr.dataset.attestationId = gap.pending_attestation_id ? String(gap.pending_attestation_id) : "";
|
||
tr.dataset.accountId = String(gap.bank_account_id || "");
|
||
tr.dataset.gapStart = gap.gap_start || "";
|
||
tr.dataset.gapEnd = gap.gap_end || "";
|
||
const risk = document.createElement("td");
|
||
risk.innerHTML = '<span class="pill pill-danger">高</span>';
|
||
const company = document.createElement("td");
|
||
company.className = "cell-main";
|
||
company.textContent = `${gap.company_name || "—"} · ${gap.account_number_masked || gap.bank_account_id}`;
|
||
const type = document.createElement("td");
|
||
type.textContent = "流水断档";
|
||
const period = document.createElement("td");
|
||
period.className = "num";
|
||
period.textContent = `${gap.gap_start || "—"} — ${gap.gap_end || "—"}`;
|
||
const impact = document.createElement("td");
|
||
impact.className = "wrap";
|
||
impact.textContent = `${gap.gap_kind || "gap"} · ${gap.day_count || "?"} 天`;
|
||
const status = document.createElement("td");
|
||
const statusPill = document.createElement("span");
|
||
statusPill.className = "pill pill-warn";
|
||
statusPill.textContent = gap.pending_attestation_id ? "待审说明" : "待补传";
|
||
status.append(statusPill);
|
||
const action = document.createElement("td");
|
||
if (gap.pending_attestation_id) {
|
||
action.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve-attestation">通过说明</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject-attestation">驳回说明</button></div>';
|
||
} else {
|
||
action.innerHTML = '<button type="button" class="btn btn-sm" data-view-link="reminders">去提醒</button>';
|
||
}
|
||
tr.append(risk, company, type, period, impact, status, action);
|
||
tbody.prepend(tr);
|
||
});
|
||
}
|
||
|
||
async function loadAdminCoverageGaps() {
|
||
const response = await fetch("/api/admin/coverage-gaps?status=open").catch(() => null);
|
||
if (!response?.ok) return;
|
||
const data = await response.json().catch(() => null);
|
||
state.coverageGaps = data?.items || [];
|
||
appendAuditGapRows(state.coverageGaps);
|
||
updateAuditCounts();
|
||
const badge = $('.side-nav a[data-view="audit"] .nav-badge');
|
||
if (badge) {
|
||
const n = state.coverageGaps.length || 0;
|
||
badge.textContent = String(n || "");
|
||
badge.hidden = n <= 0;
|
||
}
|
||
}
|
||
|
||
async function loadCompanyCoverageGaps() {
|
||
const response = await fetch("/api/company/coverage-gaps").catch(() => null);
|
||
if (!response?.ok) return [];
|
||
const data = await response.json().catch(() => null);
|
||
const items = (data?.items || []).filter((item) => item.status === "open");
|
||
state.companyCoverageGaps = items;
|
||
const renderNotice = (rootId, bodyId) => {
|
||
const root = $(rootId);
|
||
const body = $(bodyId);
|
||
if (!root || !body) return;
|
||
if (!items.length) {
|
||
root.style.display = "none";
|
||
return;
|
||
}
|
||
root.style.display = "";
|
||
body.textContent = items.slice(0, 3).map((g) => {
|
||
const acct = g.account_number_masked || g.bank_account_id;
|
||
return `${acct}:${g.gap_start} — ${g.gap_end}(${g.day_count || "?"}天)`;
|
||
}).join(";") + (items.length > 3 ? ` 等 ${items.length} 处` : "");
|
||
};
|
||
renderNotice("#companyCoverageNotice", "#companyCoverageBody");
|
||
renderNotice("#flowsCoverageNotice", "#flowsCoverageBody");
|
||
return items;
|
||
}
|
||
|
||
function openAttestationDialog(gap) {
|
||
if (!gap) {
|
||
const gaps = state.companyCoverageGaps || [];
|
||
gap = gaps[0];
|
||
}
|
||
if (!gap) {
|
||
showToast("暂无断档", "当前没有可说明的断档区间", "warn");
|
||
return;
|
||
}
|
||
$("#att-account-id").value = gap.bank_account_id || "";
|
||
$("#att-gap-start").value = gap.gap_start || "";
|
||
$("#att-gap-end").value = gap.gap_end || "";
|
||
const label = $("#att-gap-label");
|
||
if (label) {
|
||
label.textContent = `${gap.account_number_masked || gap.bank_account_id} · ${gap.gap_start} — ${gap.gap_end}`;
|
||
}
|
||
$("#att-reason").value = "";
|
||
$("#att-evidence").value = "";
|
||
openModal("attestationDialog");
|
||
}
|
||
|
||
|
||
function formatWan(value) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return "0.00";
|
||
const abs = Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
if (num > 0) return `+${abs}`;
|
||
if (num < 0) return `-${abs}`;
|
||
return abs;
|
||
}
|
||
|
||
function netClass(value) {
|
||
const num = Number(value);
|
||
if (num > 0) return "amt-in";
|
||
if (num < 0) return "amt-out";
|
||
return "";
|
||
}
|
||
|
||
function updateDashAuditCard(audit) {
|
||
applyAuditCounts(audit, { fromApi: true });
|
||
}
|
||
|
||
function renderDashCompanyRows(companies, selectedId) {
|
||
const list = $("#dashCompanyRows");
|
||
if (!list) return;
|
||
if (!companies.length) {
|
||
list.innerHTML = '<div class="dash-company-empty muted">暂无公司或尚无归集往来</div>';
|
||
return;
|
||
}
|
||
const monthLabel = state.dashPeriodMonth ? `${state.dashPeriodMonth}月` : "";
|
||
list.replaceChildren(...companies.map((company) => {
|
||
const item = document.createElement("div");
|
||
item.className = "dash-company-item";
|
||
item.setAttribute("role", "option");
|
||
item.dataset.companyId = company.id;
|
||
item.tabIndex = 0;
|
||
if (String(company.id) === String(selectedId)) {
|
||
item.classList.add("is-selected");
|
||
item.setAttribute("aria-selected", "true");
|
||
} else {
|
||
item.setAttribute("aria-selected", "false");
|
||
}
|
||
const main = document.createElement("div");
|
||
main.className = "dash-company-main";
|
||
const name = document.createElement("span");
|
||
name.className = "dash-company-name";
|
||
name.title = company.name;
|
||
name.textContent = company.name;
|
||
const meta = document.createElement("span");
|
||
meta.className = "dash-company-meta";
|
||
const statusLabel = company.period_status_label || "—";
|
||
meta.textContent = monthLabel
|
||
? `${company.detail_count ?? 0}笔 · ${monthLabel} ${statusLabel}`
|
||
: `${company.detail_count ?? 0}笔 · ${statusLabel}`;
|
||
main.append(name, meta);
|
||
const net = document.createElement("span");
|
||
net.className = `dash-company-net ${netClass(company.net_wan)}`;
|
||
net.textContent = formatWan(company.net_wan);
|
||
item.append(main, net);
|
||
return item;
|
||
}));
|
||
}
|
||
|
||
function chevronSvg(expanded) {
|
||
// Same inline chevron style used elsewhere in the deployed admin shell.
|
||
if (expanded) {
|
||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m6 9 6 6 6-6"/></svg>';
|
||
}
|
||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m9 18 6-6-6-6"/></svg>';
|
||
}
|
||
|
||
function formatPlainWan(value) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return "0.00";
|
||
return Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function updateDashDetailSummary(company, payload) {
|
||
const bar = $("#dashDetailSummary");
|
||
if (!bar) return;
|
||
if (!company) {
|
||
bar.hidden = true;
|
||
return;
|
||
}
|
||
bar.hidden = false;
|
||
const count = payload?.groups
|
||
? payload.groups.reduce((sum, g) => sum + Number(g.count || 0), 0)
|
||
: Number(company.detail_count || 0);
|
||
const debit = payload?.groups
|
||
? payload.groups.reduce((sum, g) => sum + Number(g.debit_wan || 0), 0)
|
||
: Number(company.debit_wan || 0);
|
||
const credit = payload?.groups
|
||
? payload.groups.reduce((sum, g) => sum + Number(g.credit_wan || 0), 0)
|
||
: Number(company.credit_wan || 0);
|
||
const ending = payload?.groups
|
||
? payload.groups.reduce((sum, g) => sum + Number(g.ending_wan || 0), 0)
|
||
: Number(company.net_wan || 0);
|
||
const setText = (id, text, className = "") => {
|
||
const el = $(id);
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.className = `dash-summary-value num ${className}`.trim();
|
||
};
|
||
setText("#dashSumCount", String(count));
|
||
setText("#dashSumOpening", "—", "meta");
|
||
setText("#dashSumDebit", formatPlainWan(debit));
|
||
setText("#dashSumCredit", formatPlainWan(credit));
|
||
setText("#dashSumEnding", formatWan(ending), netClass(ending));
|
||
}
|
||
|
||
function showDashDetailEmpty(company) {
|
||
const wrap = $("#dashDetailWrap");
|
||
const empty = $("#dashDetailEmpty");
|
||
const desc = $("#dashDetailEmptyDesc");
|
||
if (wrap) wrap.hidden = true;
|
||
if (empty) empty.hidden = false;
|
||
if (desc) {
|
||
const cutoff = state.dashCutoff || "—";
|
||
// 方案 A 空态:两行克制说明;期初口径仍为不可用(—),不伪造 0.00
|
||
desc.textContent = `期初 — · 截至 ${cutoff} 无明细记录 · 导入流水并归集后在此展示`;
|
||
}
|
||
updateDashDetailSummary(null);
|
||
}
|
||
|
||
function showDashDetailTable() {
|
||
const wrap = $("#dashDetailWrap");
|
||
const empty = $("#dashDetailEmpty");
|
||
if (wrap) wrap.hidden = false;
|
||
if (empty) empty.hidden = true;
|
||
}
|
||
|
||
function renderDashDetail(payload, { expandFirst = false, company = null } = {}) {
|
||
const tbody = $("#dashDetailRows");
|
||
const title = $("#dashDetailTitle");
|
||
if (!tbody) return;
|
||
const selected = company || (state.dashCompanies || []).find(
|
||
(c) => String(c.id) === String(payload?.company_id || state.dashSelectedId)
|
||
) || null;
|
||
|
||
if (!payload) {
|
||
if (title) title.textContent = "请选择左侧公司";
|
||
showDashDetailTable();
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>';
|
||
updateDashDetailSummary(null);
|
||
return;
|
||
}
|
||
if (title) title.textContent = payload.company_name || selected?.name || "公司往来明细";
|
||
const sub = $("#dashDetailSub");
|
||
if (sub) sub.textContent = "按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列";
|
||
|
||
const groups = payload.groups || [];
|
||
if (!groups.length) {
|
||
showDashDetailEmpty(selected || { detail_count: 0, debit_wan: 0, credit_wan: 0, net_wan: 0 });
|
||
tbody.replaceChildren();
|
||
return;
|
||
}
|
||
|
||
showDashDetailTable();
|
||
updateDashDetailSummary(selected, payload);
|
||
const rows = [];
|
||
groups.forEach((group, index) => {
|
||
const expanded = expandFirst && index === 0;
|
||
const groupTr = document.createElement("tr");
|
||
groupTr.className = "clickable";
|
||
groupTr.dataset.peerGroup = String(group.peer_id);
|
||
groupTr.dataset.peerName = group.peer_name;
|
||
groupTr.dataset.expanded = expanded ? "true" : "false";
|
||
groupTr.innerHTML = `
|
||
<td style="font-weight:550;">${chevronSvg(expanded)}<span data-peer-label>${group.peer_name}</span></td>
|
||
<td class="num-col">${group.count}</td>
|
||
<td class="num-col meta">—</td>
|
||
<td class="num-col">${Number(group.debit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||
<td class="num-col">${Number(group.credit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||
<td class="num-col ${netClass(group.ending_wan)}">${formatWan(group.ending_wan)}</td>`;
|
||
rows.push(groupTr);
|
||
(group.lines || []).forEach((line) => {
|
||
const lineTr = document.createElement("tr");
|
||
lineTr.dataset.peerChild = String(group.peer_id);
|
||
lineTr.style.display = expanded ? "" : "none";
|
||
const dirTag = line.direction === "debit"
|
||
? '<span class="tag">借</span>'
|
||
: '<span class="tag">贷</span>';
|
||
lineTr.innerHTML = `
|
||
<td style="padding-left: 28px;">${dirTag} <span class="meta">${line.date}</span> ${line.summary}</td>
|
||
<td class="num-col">1</td>
|
||
<td class="num-col meta">—</td>
|
||
<td class="num-col">${line.direction === "debit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
|
||
<td class="num-col">${line.direction === "credit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
|
||
<td class="num-col meta">—</td>`;
|
||
rows.push(lineTr);
|
||
});
|
||
});
|
||
tbody.replaceChildren(...rows);
|
||
}
|
||
|
||
async function resolveDashStartDate() {
|
||
if (state.dashFrom) return state.dashFrom;
|
||
const response = await fetch("/api/admin/settings").catch(() => null);
|
||
if (response?.ok) {
|
||
const result = await response.json().catch(() => null);
|
||
const startDate = result?.settings?.start_date;
|
||
if (startDate) {
|
||
state.dashFrom = startDate;
|
||
return startDate;
|
||
}
|
||
}
|
||
return "2026-01-01";
|
||
}
|
||
|
||
async function loadDashCompanyDetail(companyId) {
|
||
if (!companyId) return;
|
||
state.dashSelectedId = companyId;
|
||
const company = (state.dashCompanies || []).find((c) => String(c.id) === String(companyId)) || null;
|
||
renderDashCompanyRows(state.dashCompanies || [], companyId);
|
||
const title = $("#dashDetailTitle");
|
||
if (title && company) title.textContent = company.name;
|
||
updateDashDetailSummary(company, null);
|
||
showDashDetailTable();
|
||
const tbody = $("#dashDetailRows");
|
||
if (tbody) tbody.innerHTML = '<tr class="loading-row"><td colspan="6">加载中…</td></tr>';
|
||
const from = await resolveDashStartDate();
|
||
const response = await fetch(`/api/admin/dashboard/companies/${companyId}?from=${encodeURIComponent(from)}`).catch(() => null);
|
||
if (!response?.ok) {
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">明细加载失败</td></tr>';
|
||
return;
|
||
}
|
||
const result = await response.json().catch(() => null);
|
||
renderDashDetail(result, { company });
|
||
}
|
||
|
||
async function loadAdminDashboard() {
|
||
if (!$("#dashCompanyRows")) return;
|
||
const from = await resolveDashStartDate();
|
||
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
|
||
if (!response?.ok) {
|
||
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
|
||
return;
|
||
}
|
||
const data = await response.json().catch(() => null);
|
||
if (!data || data.status !== "ok") {
|
||
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
|
||
return;
|
||
}
|
||
state.dashCompanies = data.companies || [];
|
||
state.dashFrom = data.from_date;
|
||
state.dashCutoff = data.cutoff;
|
||
state.dashPeriodMonth = data.period_month;
|
||
updateDashAuditCard(data.audit);
|
||
const listSub = $("#dashListSub");
|
||
if (listSub) {
|
||
listSub.textContent = `${data.from_date} 至 ${data.cutoff} · 单位:万元 · 左侧选择公司,右侧查看其往来明细`;
|
||
}
|
||
const meta = $("#dashMasterMeta");
|
||
if (meta) {
|
||
const totals = data.totals || {};
|
||
const companyCount = totals.company_count ?? state.dashCompanies.length;
|
||
const detailCount = totals.detail_count ?? 0;
|
||
meta.textContent = `${companyCount} 家公司 · 明细 ${detailCount} 笔`;
|
||
}
|
||
const selected = state.dashSelectedId || state.dashCompanies[0]?.id;
|
||
renderDashCompanyRows(state.dashCompanies, selected);
|
||
if (selected) await loadDashCompanyDetail(selected);
|
||
}
|
||
|
||
function initDashboard() {
|
||
if (!$("#companyMasterDetail")) return;
|
||
const selectCompany = (companyId) => {
|
||
if (!companyId) return;
|
||
loadDashCompanyDetail(companyId);
|
||
};
|
||
$("#dashCompanyRows")?.addEventListener("click", (event) => {
|
||
const item = event.target.closest(".dash-company-item[data-company-id]");
|
||
if (!item) return;
|
||
selectCompany(item.dataset.companyId);
|
||
});
|
||
$("#dashCompanyRows")?.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
const item = event.target.closest(".dash-company-item[data-company-id]");
|
||
if (!item) return;
|
||
event.preventDefault();
|
||
selectCompany(item.dataset.companyId);
|
||
});
|
||
$("#dashDetailRows")?.addEventListener("click", (event) => {
|
||
const tr = event.target.closest("tr[data-peer-group]");
|
||
if (!tr) return;
|
||
const peerId = tr.dataset.peerGroup;
|
||
const open = tr.dataset.expanded !== "true";
|
||
tr.dataset.expanded = open ? "true" : "false";
|
||
const nameCell = tr.children[0];
|
||
if (nameCell) {
|
||
nameCell.innerHTML = `${chevronSvg(open)}<span data-peer-label>${tr.dataset.peerName || ""}</span>`;
|
||
}
|
||
$$('#dashDetailRows tr[data-peer-child]').forEach((child) => {
|
||
if (child.dataset.peerChild === peerId) child.style.display = open ? "" : "none";
|
||
});
|
||
});
|
||
$("#company-search")?.addEventListener("input", function () {
|
||
const q = this.value.trim();
|
||
$$("#dashCompanyRows .dash-company-item[data-company-id]").forEach((item) => {
|
||
item.style.display = (!q || item.textContent.includes(q)) ? "" : "none";
|
||
});
|
||
});
|
||
loadAdminDashboard();
|
||
}
|
||
|
||
async function loadAdminCompanies() {
|
||
const tbody = $("#companyTable tbody");
|
||
showTableLoading(tbody, 6);
|
||
const response = await fetch("/api/admin/companies").catch(() => null);
|
||
if (!response?.ok) {
|
||
showTableError(tbody, 6);
|
||
return;
|
||
}
|
||
const result = await response.json().catch(() => null);
|
||
const companies = result?.companies || [];
|
||
state.companies = companies;
|
||
renderAdminCompanyTable(companies);
|
||
fillCompanySelects(companies.map((company) => company.name));
|
||
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
|
||
}
|
||
|
||
function initAdmin() {
|
||
renderStoredAdminReviews();
|
||
updateAuditCounts();
|
||
loadAdminCompanies();
|
||
loadCalculationSettings();
|
||
loadOpeningBalances();
|
||
loadCalculationChanges();
|
||
loadAdminCoverageGaps();
|
||
initDashboard();
|
||
|
||
let activeAuditType = "all";
|
||
function filterAuditRows() {
|
||
const company = $("#auditCompany")?.value || "全部公司";
|
||
$$(".audit-table tbody tr").forEach((row) => {
|
||
const typeMatches = activeAuditType === "all" || row.dataset.auditType === activeAuditType;
|
||
const companyMatches = company === "全部公司" || row.dataset.company === company;
|
||
row.hidden = !(typeMatches && companyMatches);
|
||
});
|
||
}
|
||
$$("[data-audit-filter]").forEach((button) => button.addEventListener("click", () => {
|
||
activeAuditType = button.dataset.auditFilter;
|
||
$$("[data-audit-filter]").forEach((item) => {
|
||
const active = item === button;
|
||
item.classList.toggle("active", active);
|
||
item.setAttribute("aria-pressed", String(active));
|
||
});
|
||
filterAuditRows();
|
||
}));
|
||
$("#auditCompany")?.addEventListener("change", filterAuditRows);
|
||
|
||
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
|
||
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
|
||
|
||
async function submitAuditResult(row) {
|
||
const decision = state.auditDecision;
|
||
const reason = state.auditReason || "";
|
||
const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用") || decision.includes("关闭异常");
|
||
const returned = decision.includes("退回");
|
||
const kind = row.dataset.recordKind;
|
||
let storedStatus;
|
||
let reviewedAccount = null;
|
||
|
||
if (kind === "account" && row.dataset.accountId) {
|
||
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
|
||
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ decision: apiDecision, reason }),
|
||
}).catch(() => null);
|
||
if (response?.status === 401) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
showToast("审核结果提交失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
reviewedAccount = result.account;
|
||
storedStatus = accountStatusLabel(result.account?.status);
|
||
} else if (kind === "manual" && row.dataset.recordId) {
|
||
const action = approved ? "approve_new" : "return";
|
||
const response = await fetch(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
action,
|
||
reason: reason || decision,
|
||
expected_decision_id: row.dataset.decisionId ? Number(row.dataset.decisionId) : null,
|
||
request_key: `audit-manual-${row.dataset.recordId}-${Date.now()}`,
|
||
subject_code: row.dataset.requestedSubject || undefined,
|
||
}),
|
||
}).catch(() => null);
|
||
if (response?.status === 401) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
showToast("手工单审核失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
storedStatus = approved ? "已确认" : "已退回";
|
||
} else if (kind === "match" && row.dataset.eventId) {
|
||
// 既有 decision:撤销当前匹配决定,使异常退出待办队列(与 reverse 语义一致)。
|
||
const response = await fetch(`/api/admin/transfer-events/${row.dataset.eventId}/decisions`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
action: "reverse",
|
||
reason: reason || decision || "审核中心关闭匹配异常",
|
||
expected_revision: row.dataset.revision ? Number(row.dataset.revision) : null,
|
||
request_key: `audit-match-${row.dataset.eventId}-${Date.now()}`,
|
||
}),
|
||
}).catch(() => null);
|
||
if (response?.status === 401) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
showToast("匹配异常处置失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
storedStatus = approved ? "已确认" : "已退回";
|
||
} else {
|
||
showToast("审核结果提交失败", "未知待办类型", "danger");
|
||
return;
|
||
}
|
||
|
||
// 处置后从列表移除并重拉三队列,保证页脚/过滤/标题与角标同步减一。
|
||
row.remove();
|
||
updateAuditCounts();
|
||
await refreshAuditCountsFromApi();
|
||
const unresolved = $$("#auditRows tr").filter((r) => r.dataset.resolved !== "true");
|
||
const high = unresolved.filter((r) => r.querySelector(".pill-danger")).length;
|
||
const medium = unresolved.filter((r) => r.querySelector(".pill-warn")).length;
|
||
const low = Math.max(0, unresolved.length - high - medium);
|
||
applyAuditCounts({ total: unresolved.length, high, medium, low }, { fromApi: true });
|
||
updatePendingAccountNotice();
|
||
filterAuditRows();
|
||
showToast(
|
||
"审核结果已记录",
|
||
approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算",
|
||
"success",
|
||
);
|
||
}
|
||
|
||
$("#auditRows")?.addEventListener("click", async (event) => {
|
||
const button = event.target.closest("[data-audit-action]");
|
||
if (!button) return;
|
||
if (button.dataset.auditAction === "approve-attestation" || button.dataset.auditAction === "reject-attestation") {
|
||
const row = button.closest("tr");
|
||
const attestationId = row?.dataset.attestationId;
|
||
if (!attestationId) return;
|
||
const approve = button.dataset.auditAction === "approve-attestation";
|
||
const reason = await askReason({
|
||
title: approve ? "通过无业务说明" : "驳回无业务说明",
|
||
subtitle: "审核结论将写入留痕;通过后仅关闭断档,不生成银行行。",
|
||
confirmLabel: approve ? "通过" : "驳回",
|
||
});
|
||
if (!reason) return;
|
||
const response = await fetch(`/api/admin/no-business-attestations/${attestationId}/review`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ decision: approve ? "approve" : "reject", review_reason: reason }),
|
||
}).catch(() => null);
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response?.ok) {
|
||
showToast("审核失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
await loadAdminCoverageGaps();
|
||
showToast(approve ? "已通过说明" : "已驳回说明", "断档状态已更新", "success");
|
||
return;
|
||
}
|
||
const row = button.closest("tr");
|
||
state.auditRow = row;
|
||
const cells = $$("td", row);
|
||
const company = cells[1]?.querySelector(".cell-main")?.textContent.trim() || "";
|
||
const period = cells[3]?.textContent.trim() || "";
|
||
const type = cells[2]?.textContent.trim() || "";
|
||
const basis = row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明";
|
||
const action = button.dataset.auditAction;
|
||
if (action === "approve") {
|
||
$("#approve-company").textContent = company;
|
||
$("#approve-period").textContent = period;
|
||
$("#approve-type").textContent = type;
|
||
$("#approve-basis").textContent = basis;
|
||
openModal("modal-approve");
|
||
} else if (action === "reject") {
|
||
$("#reject-reason").value = "";
|
||
$("#reject-hint").style.display = "none";
|
||
openModal("modal-reject");
|
||
} else if (action === "disable") {
|
||
state.auditDecision = "停用并驳回";
|
||
state.auditReason = "停用并驳回该账户";
|
||
submitAuditResult(row);
|
||
}
|
||
});
|
||
|
||
$("#approve-confirm")?.addEventListener("click", () => {
|
||
if (!state.auditRow) return;
|
||
closeModal("modal-approve");
|
||
const kind = state.auditRow.dataset.recordKind;
|
||
state.auditDecision = kind === "account"
|
||
? "复核通过并启用账户"
|
||
: kind === "match"
|
||
? "关闭异常"
|
||
: "确认并纳入计算";
|
||
state.auditReason = state.auditDecision;
|
||
submitAuditResult(state.auditRow);
|
||
});
|
||
|
||
$("#reject-confirm")?.addEventListener("click", () => {
|
||
if (!state.auditRow) return;
|
||
const reason = $("#reject-reason").value.trim();
|
||
if (reason.length < 5) { $("#reject-hint").style.display = ""; return; }
|
||
closeModal("modal-reject");
|
||
const kind = state.auditRow.dataset.recordKind;
|
||
state.auditDecision = kind === "account"
|
||
? "退回公司修改"
|
||
: kind === "match"
|
||
? "退回重匹配"
|
||
: "退回公司补充材料";
|
||
state.auditReason = reason;
|
||
submitAuditResult(state.auditRow);
|
||
});
|
||
|
||
$("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog"));
|
||
$("#companyForm")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const data = new FormData(form);
|
||
const companyName = String(data.get("companyName") || "").trim();
|
||
const loginName = String(data.get("loginName") || "").trim();
|
||
const createUser = data.get("createUser") !== null;
|
||
|
||
const payload = {
|
||
name: companyName,
|
||
credit_code: String(data.get("creditCode") || "").trim(),
|
||
cashier_name: String(data.get("cashier") || "").trim(),
|
||
};
|
||
if (createUser && loginName) payload.username = loginName;
|
||
|
||
const response = await fetch("/api/admin/companies", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
}).catch(() => null);
|
||
if (response?.status === 401) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
showToast("公司创建失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
const accountCreated = Boolean(result.username);
|
||
closeModal("companyDialog");
|
||
form.reset();
|
||
await loadAdminCompanies();
|
||
showToast(
|
||
accountCreated ? "公司与账号已创建" : "公司已创建",
|
||
accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
|
||
"success",
|
||
);
|
||
});
|
||
|
||
function openCompanyDetail(companyId) {
|
||
const company = (state.companies || []).find((c) => String(c.id) === String(companyId));
|
||
if (!company) return;
|
||
$("#d-title").textContent = company.name;
|
||
$("#d-sub").textContent = `统一社会信用代码 ${company.credit_code || "待补充"}`;
|
||
$("#d-kv").innerHTML =
|
||
'<dt>公司名称</dt><dd>' + company.name + '</dd>' +
|
||
'<dt>统一社会信用代码</dt><dd>' + (company.credit_code || "待补充") + '</dd>' +
|
||
'<dt>出纳</dt><dd>' + (company.cashier_name || "未指定") + '</dd>' +
|
||
'<dt>银行账户</dt><dd>' + (company.account_count ?? 0) + ' 个</dd>' +
|
||
'<dt>状态</dt><dd>' + companyStatusBadge(company.status).label + '</dd>';
|
||
$("#d-login-account").textContent = company.usernames || "未创建公司账号";
|
||
$("#d-login-meta").textContent = company.usernames ? "公司端登录账号" : "该公司尚未创建登录账号";
|
||
const resetBtn = $("#btn-reset-pwd");
|
||
resetBtn.disabled = !company.usernames;
|
||
resetBtn.textContent = "重置密码";
|
||
resetBtn.dataset.companyId = company.id;
|
||
openModal("modal-detail");
|
||
}
|
||
|
||
$("#companyTable tbody")?.addEventListener("click", (event) => {
|
||
const btn = event.target.closest("[data-company-view]");
|
||
if (btn) openCompanyDetail(btn.dataset.companyView);
|
||
});
|
||
|
||
$("#btn-reset-pwd")?.addEventListener("click", async () => {
|
||
const companyId = $("#btn-reset-pwd").dataset.companyId;
|
||
const usersResponse = await fetch("/api/admin/users").catch(() => null);
|
||
const usersResult = await usersResponse?.json().catch(() => ({}));
|
||
const user = (usersResult?.users || []).find((u) => String(u.company_id) === String(companyId) && u.role === "company");
|
||
if (!user) { showToast("重置失败", "该公司尚无公司登录账号", "danger"); return; }
|
||
const response = await fetch(`/api/admin/users/${user.id}/reset-password`, { method: "POST" }).catch(() => null);
|
||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; }
|
||
$("#btn-reset-pwd").disabled = true;
|
||
$("#btn-reset-pwd").textContent = "已重置";
|
||
$("#d-login-meta").textContent = `临时密码已生成(仅此一次):${result.initial_password},首次登录必须修改`;
|
||
showToast("密码已重置", "临时密码仅本次显示,首次登录必须修改", "success");
|
||
});
|
||
|
||
const remind = $("#cs-remind");
|
||
const track = $("#cs-switch-track");
|
||
const thumb = $("#cs-switch-thumb");
|
||
const daysSel = $("#cs-remind-days");
|
||
function renderSwitch() {
|
||
if (!remind) return;
|
||
track.style.background = remind.checked ? "var(--accent)" : "var(--fg-soft)";
|
||
thumb.style.left = remind.checked ? "18px" : "2px";
|
||
if (daysSel) daysSel.disabled = !remind.checked;
|
||
}
|
||
remind?.addEventListener("change", renderSwitch);
|
||
renderSwitch();
|
||
|
||
function applySystemSettings(values) {
|
||
if (!values) return;
|
||
const start = $("#cs-start");
|
||
if (start && values.start_date) start.value = values.start_date;
|
||
const dayInput = $("#cs-day");
|
||
if (dayInput && values.closing_day) dayInput.value = values.closing_day;
|
||
const remindInput = $("#cs-remind");
|
||
if (remindInput && "auto_remind" in values) {
|
||
remindInput.checked = values.auto_remind === "1";
|
||
renderSwitch();
|
||
}
|
||
const daysSelect = $("#cs-remind-days");
|
||
if (daysSelect && values.remind_days) daysSelect.value = values.remind_days;
|
||
const closingDay = values.closing_day || "5";
|
||
const startDate = values.start_date || "2026-01-01";
|
||
const dash = $("#dashboardPeriodSub");
|
||
if (dash) dash.textContent = `每月 ${closingDay} 日结账 · 全局起算日 ${startDate}`;
|
||
const timeline = $("#timelineSub");
|
||
if (timeline) timeline.textContent = `全局起算日 ${startDate} 起,每月 ${closingDay} 日结账`;
|
||
const flows = $("#flowsRangeMeta");
|
||
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账`;
|
||
const opening = $("#openingSub");
|
||
if (opening) opening.textContent = `${startDate} 起算的公司间往来期初数`;
|
||
}
|
||
|
||
async function loadSystemSettings() {
|
||
const response = await fetch("/api/admin/settings").catch(() => null);
|
||
if (response?.status === 401) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (response?.ok && result.settings) applySystemSettings(result.settings);
|
||
}
|
||
|
||
$("#systemSettings")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const tip = $("#cs-save-tip");
|
||
const day = parseInt($("#cs-day")?.value, 10);
|
||
if (!day || day < 1 || day > 28) {
|
||
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; }
|
||
return;
|
||
}
|
||
const startInput = $("#cs-start");
|
||
const nextStart = startInput?.value || "";
|
||
const prevStart = startInput?.dataset.current || "";
|
||
const locked = startInput?.dataset.locked === "1";
|
||
if (nextStart !== prevStart) {
|
||
if (locked) {
|
||
showToast("起算日已锁定", "已有结账月份,起算日不可修改", "warn");
|
||
return;
|
||
}
|
||
const reason = await askReason({
|
||
title: "修改起算日",
|
||
subtitle: "修改起算日必须填写原因,并写入变更留痕。",
|
||
confirmLabel: "确认修改",
|
||
});
|
||
if (!reason) return;
|
||
const calcResp = await fetch("/api/admin/settings/calculation-start", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ calculation_start_date: nextStart, reason }),
|
||
}).catch(() => null);
|
||
if (calcResp?.status === 401) { window.location.href = "index.html"; return; }
|
||
const calcResult = await calcResp?.json().catch(() => ({}));
|
||
if (!calcResp?.ok) {
|
||
showToast("起算日保存失败", calcResult?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
await loadCalculationSettings();
|
||
await loadCalculationChanges();
|
||
}
|
||
const payload = {
|
||
closing_day: String(day),
|
||
auto_remind: $("#cs-remind")?.checked ? "1" : "0",
|
||
remind_days: $("#cs-remind-days")?.value || "3",
|
||
};
|
||
// Keep display start_date in settings store in sync when calculation start exists.
|
||
if (nextStart) payload.start_date = nextStart;
|
||
const response = await fetch("/api/admin/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
}).catch(() => null);
|
||
if (response?.status === 401) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
const message = result?.message || "保存失败,请稍后重试";
|
||
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = message; }
|
||
showToast("设置保存失败", message, "danger");
|
||
return;
|
||
}
|
||
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
|
||
applySystemSettings(result.settings || {});
|
||
await loadCalculationSettings();
|
||
showToast("系统计算口径已保存", "结账日与起算日变更已留痕", "success");
|
||
});
|
||
|
||
$("#runClosingCheck")?.addEventListener("click", () => {
|
||
const unresolved = $$(".audit-table tbody tr").filter((row) => row.dataset.resolved !== "true").length;
|
||
const blockNotice = $("#block-notice");
|
||
if (unresolved) {
|
||
if (blockNotice) blockNotice.style.display = "";
|
||
$("#closingDescription").textContent = `2026 年 7 月 · 仍有 ${unresolved} 项审核事项未处理`;
|
||
$("#closingStatus").className = "pill pill-danger";
|
||
$("#closingStatus").textContent = "已阻断";
|
||
$("#executeClosing").disabled = true;
|
||
showToast("结账检查未通过", `仍有 ${unresolved} 项审核事项,已打开审核中心`, "warn");
|
||
showView("audit");
|
||
} else {
|
||
if (blockNotice) blockNotice.style.display = "none";
|
||
$$("#closingPanel [data-closing-state]").forEach((item) => {
|
||
item.className = "pill pill-success";
|
||
item.textContent = "已通过";
|
||
});
|
||
$("#closingDescription").textContent = "2026 年 7 月 · 全部前置检查已通过";
|
||
$("#closingStatus").className = "pill pill-success";
|
||
$("#closingStatus").textContent = "可结账";
|
||
$("#executeClosing").disabled = false;
|
||
$("#executeClosing").removeAttribute("title");
|
||
showToast("结账检查通过", "执行结账按钮已解锁", "success");
|
||
}
|
||
});
|
||
|
||
$("#executeClosing")?.addEventListener("click", () => openModal("closingDialog"));
|
||
$$("[data-close-closing]").forEach((button) => button.addEventListener("click", () => closeModal("closingDialog")));
|
||
$("#closingForm")?.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
closeModal("closingDialog");
|
||
$("#closingDescription").textContent = "2026 年 7 月 · 已完成集团结账";
|
||
$("#closingStatus").className = "pill pill-success";
|
||
$("#closingStatus").textContent = "已结账";
|
||
$("#closingHistory").textContent = `${new Date().toLocaleString("zh-CN", { hour12: false })} · 系统管理员执行 2026 年 7 月结账 · 已写入审计记录`;
|
||
$("#runClosingCheck").disabled = true;
|
||
$("#executeClosing").disabled = true;
|
||
$("#executeClosing").textContent = "7 月已结账";
|
||
const closedNotice = $("#closed-notice");
|
||
if (closedNotice) closedNotice.style.display = "";
|
||
const tlCurrent = $("#tl-current");
|
||
tlCurrent?.classList.remove("current");
|
||
tlCurrent?.classList.add("closed");
|
||
$("#tl-current-state").textContent = "已结账";
|
||
showToast("2026 年 7 月已完成结账", "本期结果已锁定,后续补录将进入重开流程", "success");
|
||
});
|
||
|
||
$("#openOpeningDialog")?.addEventListener("click", () => openModal("openingDialog"));
|
||
$$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog")));
|
||
$("#openingForm")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const data = new FormData(form);
|
||
const fromName = data.get("from");
|
||
const toName = data.get("to");
|
||
if (fromName === toName) {
|
||
showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初", "warn");
|
||
return;
|
||
}
|
||
const fromCompany = (state.companies || []).find((c) => c.name === fromName);
|
||
const toCompany = (state.companies || []).find((c) => c.name === toName);
|
||
if (!fromCompany || !toCompany) {
|
||
showToast("公司无效", "请刷新页面后重试", "warn");
|
||
return;
|
||
}
|
||
const amount = Number(data.get("amount"));
|
||
if (Number.isNaN(amount)) {
|
||
showToast("金额无效", "请输入有效数字", "warn");
|
||
return;
|
||
}
|
||
const response = await fetch("/api/admin/opening-balances", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
from_company_id: fromCompany.id,
|
||
to_company_id: toCompany.id,
|
||
amount: String(amount),
|
||
reason: String(data.get("reason") || ""),
|
||
}),
|
||
}).catch(() => null);
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response?.ok) {
|
||
showToast("提交失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
closeModal("openingDialog");
|
||
form.reset();
|
||
await loadOpeningBalances();
|
||
await loadCalculationChanges();
|
||
await loadCalculationSettings();
|
||
showToast("期初余额已提交", "待确认后才会计入公司端余额", "success");
|
||
});
|
||
|
||
$("#openingRows")?.addEventListener("click", async (event) => {
|
||
const confirmBtn = event.target.closest("[data-confirm-opening]");
|
||
const voidBtn = event.target.closest("[data-void-opening]");
|
||
const id = confirmBtn?.dataset.confirmOpening || voidBtn?.dataset.voidOpening;
|
||
if (!id) return;
|
||
const reason = await askReason({
|
||
title: confirmBtn ? "确认期初余额" : "作废期初余额",
|
||
subtitle: "该操作必须填写原因,并写入变更留痕。",
|
||
confirmLabel: confirmBtn ? "确认" : "作废",
|
||
});
|
||
if (!reason) return;
|
||
const path = confirmBtn
|
||
? `/api/admin/opening-balances/${id}/confirm`
|
||
: `/api/admin/opening-balances/${id}/void`;
|
||
const response = await fetch(path, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ reason }),
|
||
}).catch(() => null);
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response?.ok) {
|
||
showToast("操作失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
await loadOpeningBalances();
|
||
await loadCalculationChanges();
|
||
showToast(confirmBtn ? "期初已确认" : "期初已作废", "变更已留痕", "success");
|
||
});
|
||
|
||
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
|
||
const reminderSelect = $("#reminder-company");
|
||
const reminderItems = $("#reminder-items");
|
||
const reminderError = $("#reminder-error");
|
||
const companyError = $("#company-error");
|
||
const sendBtn = $("#send-btn");
|
||
const esc = (text) => String(text ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
const fmtReminderTime = (iso) => String(iso || "").replace("T", " ").slice(5, 16);
|
||
let pendingReminderItems = [];
|
||
|
||
function renderPendingItems(items) {
|
||
pendingReminderItems = items || [];
|
||
if (!reminderItems) return;
|
||
if (!pendingReminderItems.length) {
|
||
reminderItems.innerHTML = '<div class="empty">该公司当前没有待提醒事项。</div>';
|
||
if (sendBtn) sendBtn.disabled = true;
|
||
return;
|
||
}
|
||
reminderItems.replaceChildren(...pendingReminderItems.map((item) => {
|
||
const row = document.createElement("div");
|
||
row.className = "list-row";
|
||
row.innerHTML =
|
||
'<span class="pill pill-warn" style="flex:none;">待处理</span>' +
|
||
'<div class="lr-main"><div class="lr-title">' + esc(item.kind) + '</div>' +
|
||
'<div class="lr-sub">' + esc(item.content) + '</div></div>';
|
||
return row;
|
||
}));
|
||
if (sendBtn) sendBtn.disabled = false;
|
||
}
|
||
|
||
async function loadPendingReminders(companyId) {
|
||
if (!companyId) { renderPendingItems([]); return; }
|
||
if (reminderItems) reminderItems.innerHTML = '<div class="loading-row">正在列出待提醒事项…</div>';
|
||
if (sendBtn) sendBtn.disabled = true;
|
||
const response = await fetch("/api/admin/reminders/pending?company_id=" + encodeURIComponent(companyId)).catch(() => null);
|
||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) { renderPendingItems([]); return; }
|
||
renderPendingItems(result.items || []);
|
||
}
|
||
|
||
reminderSelect?.addEventListener("change", () => {
|
||
if (reminderError) reminderError.style.display = "none";
|
||
loadPendingReminders(reminderSelect.value);
|
||
});
|
||
|
||
$("#reminderForm")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const companyId = reminderSelect?.value;
|
||
if (companyError) companyError.style.display = companyId ? "none" : "";
|
||
if (!companyId) return;
|
||
if (!pendingReminderItems.length) {
|
||
if (reminderError) reminderError.style.display = "";
|
||
return;
|
||
}
|
||
if (reminderError) reminderError.style.display = "none";
|
||
if (sendBtn) sendBtn.disabled = true;
|
||
const response = await fetch("/api/admin/reminders/send", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ company_id: Number(companyId) }),
|
||
}).catch(() => null);
|
||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
if (sendBtn) sendBtn.disabled = false;
|
||
showToast("发送失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
const sendHint = $("#send-hint");
|
||
if (sendHint) {
|
||
sendHint.style.display = "";
|
||
sendHint.textContent = `已向 ${result.company_name} 发送 ${(result.reminders || []).length} 项提醒,截止 ${result.deadline || "—"}。`;
|
||
clearTimeout(sendHint._t);
|
||
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
|
||
}
|
||
await loadReminderHistory();
|
||
await loadPendingReminders(companyId);
|
||
showToast("提醒已发送", `已生成 ${(result.reminders || []).length} 条提醒记录`, "success");
|
||
});
|
||
|
||
// ── 提醒历史(实时从后端读取) ──
|
||
let reminderFilter = "all";
|
||
function refreshReminderCounts() {
|
||
const rows = $$("#reminder-tbody tr");
|
||
let all = rows.length, sys = 0, man = 0;
|
||
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
|
||
const countAll = $("#count-all"), countSys = $("#count-system"), countMan = $("#count-manual");
|
||
if (countAll) countAll.textContent = all;
|
||
if (countSys) countSys.textContent = sys;
|
||
if (countMan) countMan.textContent = man;
|
||
const foot = $("#table-foot-count");
|
||
if (foot) foot.textContent = "共 " + all + " 条提醒记录";
|
||
const state = $("#table-foot-state");
|
||
if (state) state.textContent = "未读 " + all + " · 处理中 0 · 已完成 0";
|
||
}
|
||
|
||
function applyReminderFilter() {
|
||
$$("#reminder-tbody tr").forEach((r) => {
|
||
r.style.display = (reminderFilter === "all" || r.dataset.source === reminderFilter) ? "" : "none";
|
||
});
|
||
}
|
||
|
||
async function loadReminderHistory() {
|
||
const tbody = $("#reminder-tbody");
|
||
if (!tbody) return;
|
||
const response = await fetch("/api/admin/reminders").catch(() => null);
|
||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||
const result = await response?.json().catch(() => ({}));
|
||
const reminders = result?.reminders || [];
|
||
tbody.replaceChildren(...reminders.map((reminder) => {
|
||
const row = document.createElement("tr");
|
||
row.dataset.source = reminder.source;
|
||
row.innerHTML =
|
||
'<td class="cell-main">' + esc(reminder.company_name) + '</td>' +
|
||
'<td><span class="tag">' + esc(reminder.kind) + '</span></td>' +
|
||
'<td class="wrap">' + esc(reminder.content) + '</td>' +
|
||
'<td class="meta">' + fmtReminderTime(reminder.created_at) + '<span class="cell-sub">' + (reminder.source === "manual" ? "人工" : "系统") + (reminder.actor_username ? " · " + esc(reminder.actor_username) : "") + '</span></td>' +
|
||
'<td class="meta">' + (reminder.deadline || "—") + '</td>' +
|
||
'<td><span class="pill pill-danger">未读</span></td>' +
|
||
'<td><button type="button" class="btn btn-sm btn-ghost act-history" data-company-id="' + reminder.company_id + '" data-company="' + esc(reminder.company_name) + '">查看历史</button></td>';
|
||
return row;
|
||
}));
|
||
applyReminderFilter();
|
||
refreshReminderCounts();
|
||
}
|
||
|
||
$("#reminder-tabs")?.addEventListener("click", (event) => {
|
||
const btn = event.target.closest("button[data-filter]");
|
||
if (!btn) return;
|
||
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
reminderFilter = btn.dataset.filter;
|
||
applyReminderFilter();
|
||
});
|
||
|
||
$("#reminder-tbody")?.addEventListener("click", (event) => {
|
||
const historyBtn = event.target.closest(".act-history");
|
||
if (historyBtn) openReminderHistory(historyBtn.dataset.companyId, historyBtn.dataset.company);
|
||
});
|
||
|
||
async function openReminderHistory(companyId, companyName) {
|
||
$("#history-title").textContent = (companyName || "") + " · 提醒历史";
|
||
$("#history-sub").textContent = "该公司的全部提醒触达记录,按时间倒序。";
|
||
const list = $("#history-list");
|
||
list.innerHTML = '<div class="loading-row">加载中…</div>';
|
||
const response = await fetch("/api/admin/reminders?company_id=" + encodeURIComponent(companyId || "")).catch(() => null);
|
||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||
const result = await response?.json().catch(() => ({}));
|
||
const items = result?.reminders || [];
|
||
if (!items.length) {
|
||
list.innerHTML = '<div class="empty"><div class="e-title">暂无历史记录</div>该公司尚未收到过提醒。</div>';
|
||
} else {
|
||
list.innerHTML = items.map((it) => {
|
||
return '<div class="list-row">' +
|
||
'<span class="tag">' + esc(it.kind) + '</span>' +
|
||
'<div class="lr-main"><div class="lr-title">' + esc(it.content) + '</div><div class="lr-sub"><span class="meta">' + fmtReminderTime(it.created_at) + ' · ' + (it.source === "manual" ? "人工" : "系统") + (it.actor_username ? " · " + esc(it.actor_username) : "") + '</span></div></div>' +
|
||
'<div class="lr-side"><span class="pill pill-danger">未读</span></div></div>';
|
||
}).join("");
|
||
}
|
||
openModal("history-modal");
|
||
}
|
||
$("#history-close")?.addEventListener("click", () => closeModal("history-modal"));
|
||
$("#history-ok")?.addEventListener("click", () => closeModal("history-modal"));
|
||
|
||
loadSystemSettings();
|
||
loadReminderHistory();
|
||
}
|
||
|
||
const FLOW_DEMO = [
|
||
{ date: "2026-07-01", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "平顶山市恒源电力燃料有限公司", summary: "煤炭销售款(6 月结算)", serial: "ICBC202607010031825", status: "未归集", statusPill: "pill-muted", amount: "1,860,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-01 09:42:17", peer: "平顶山市恒源电力燃料有限公司", peerAcct: "工行平顶山分行 1702 0218 0902 6641 20", amount: "¥ 1,860,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对方为集团外客户,不进入内部往来归集,仅作银行流水留档。" } },
|
||
{ date: "2026-07-03", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "光伏支架材料款", serial: "ICBC202607030094417", status: "单边", statusPill: "pill-danger", amount: "1,620,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-03 14:08:52", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,620,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-002", note: "贸易侧 7 月上报流水中未找到对应收款,已挂起为单边流水,待贸易侧补充银行凭证佐证。" } },
|
||
{ date: "2026-07-03", company: "金牛置业", bank: "中行", account: "8821", acctLabel: "中行 · 尾号 8821", dir: "收", dirPill: "pill-success", peer: "郑州市商品房预售资金监管专户", summary: "商品房预售款(A 区 12 号楼)", serial: "BOC202607030552108", status: "未归集", statusPill: "pill-muted", amount: "4,150,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛置业有限公司 · 一般户 2546 0387 0200 8821", time: "2026-07-03 10:26:31", peer: "郑州市商品房预售资金监管专户", peerAcct: "中行郑州郑东新区支行 2546 1180 0200 3477", amount: "¥ 4,150,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "预售监管资金划入,属对外经营收款,不参与集团内部往来归集。" } },
|
||
{ date: "2026-07-05", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "河南金牛物流有限公司", summary: "矿区运输费(6 月)", serial: "ICBC202607050127663", status: "已归集", statusPill: "pill-success", amount: "1,240,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-05 11:15:09", peer: "河南金牛物流有限公司", peerAcct: "建行郑州经开区支行 4105 0167 8080 5562", amount: "¥ 1,240,000.00(付)", status: "已归集", pair: "金牛煤业 ↔ 金牛物流", subject: "应付(煤业侧)", batch: "JC-2026-07-014", note: "与物流侧建行尾号 5562 账户 07-05 收款流水双向匹配,金额一致。" } },
|
||
{ date: "2026-07-05", company: "金牛物流", bank: "建行", account: "5562", acctLabel: "建行 · 尾号 5562", dir: "收", dirPill: "pill-success", peer: "河南金牛煤业有限公司", summary: "矿区运输费(6 月)", serial: "CCB202607050312940", status: "已归集", statusPill: "pill-success", amount: "1,240,000.00", amtClass: "amt-in", detail: { bank: "建设银行", account: "河南金牛物流有限公司 · 基本户 4105 0167 8080 5562", time: "2026-07-05 11:15:36", peer: "河南金牛煤业有限公司", peerAcct: "工行平顶山分行 1702 0231 0900 8133 05", amount: "¥ 1,240,000.00(收)", status: "已归集", pair: "金牛物流 ↔ 金牛煤业", subject: "应收(物流侧)", batch: "JC-2026-07-014", note: "与煤业侧工行尾号 3305 账户 07-05 付款流水双向匹配,金额一致。" } },
|
||
{ date: "2026-07-08", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "煤炭采购款(7 月)", serial: "ICBC202607080208554", status: "待确认", statusPill: "pill-warn", amount: "3,200,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-08 15:47:22", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 3,200,000.00(收)", status: "待确认", pair: "金牛煤业 ↔ 金牛置业", subject: "应收(煤业侧)", batch: "—(待归集)", note: "置业中行尾号 8821 账户 07-06 至 07-16 流水断档,对方付款凭证缺失,暂无法完成双边匹配,已列入审核中心高风险事项。" } },
|
||
{ date: "2026-07-09", company: "金牛贸易", bank: "农行", account: "2208", acctLabel: "农行 · 尾号 2208", dir: "收", dirPill: "pill-success", peer: "洛阳建工集团有限公司", summary: "钢材销售款(6 月发货)", serial: "ABC202607090773261", status: "未归集", statusPill: "pill-muted", amount: "2,480,000.00", amtClass: "amt-in", detail: { bank: "农业银行", account: "河南金牛贸易有限公司 · 基本户 1606 3301 0400 0220 8", time: "2026-07-09 09:58:44", peer: "洛阳建工集团有限公司", peerAcct: "中行洛阳分行 2546 2201 0500 7915", amount: "¥ 2,480,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外钢材销售回款,不参与集团内部往来归集。" } },
|
||
{ date: "2026-07-11", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "电缆及配电柜采购款", serial: "ICBC202607110158902", status: "单边", statusPill: "pill-danger", amount: "1,950,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-11 16:32:08", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,950,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-005", note: "贸易侧无对应收款记录,单边挂起。新能源↔贸易本月累计 3 笔单边流水,合计 486 万元。" } },
|
||
{ date: "2026-07-14", company: "金牛煤业", bank: "中行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "平顶山天安煤业设备租赁有限公司", summary: "综采设备租赁费(7 月)", serial: "BOC202607140416337", status: "未归集", statusPill: "pill-muted", amount: "920,000.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-14 10:11:57", peer: "平顶山天安煤业设备租赁有限公司", peerAcct: "建行平顶山分行 4105 0229 8080 1347", amount: "¥ 920,000.00(付)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "综采设备月度租赁支出,对方为集团外供应商,不参与内部归集。" } },
|
||
{ date: "2026-07-18", company: "金牛煤业", bank: "交行", account: "7710", acctLabel: "交行 · 尾号 7710", dir: "付", dirPill: "pill-danger", peer: "平顶山市安泰矿山设备有限公司", summary: "提升机大修款", serial: "BOCOM202607180062194", status: "待确认", statusPill: "pill-warn", amount: "685,400.00", amtClass: "amt-out", detail: { bank: "交通银行", account: "河南金牛煤业有限公司 · 一般户 4110 6120 0181 0077 10(账户待审核)", time: "2026-07-18 13:29:40", peer: "平顶山市安泰矿山设备有限公司", peerAcct: "工行平顶山分行 1702 0218 0902 9075 63", amount: "¥ 685,400.00(付)", status: "待确认", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "付款账户(交行尾号 7710)为新开户,尚在账户审核流程中,流水暂挂待确认,审核通过后自动归档为外部交易。" } },
|
||
{ date: "2026-07-21", company: "金牛置业", bank: "中行", account: "8821", acctLabel: "中行 · 尾号 8821", dir: "收", dirPill: "pill-success", peer: "郑州市商品房预售资金监管专户", summary: "商品房预售款(A 区 15 号楼)", serial: "BOC202607210588420", status: "未归集", statusPill: "pill-muted", amount: "3,780,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛置业有限公司 · 一般户 2546 0387 0200 8821", time: "2026-07-21 09:35:12", peer: "郑州市商品房预售资金监管专户", peerAcct: "中行郑州郑东新区支行 2546 1180 0200 3477", amount: "¥ 3,780,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "预售监管资金划入。该账户 07-06 至 07-16 存在流水断档,本笔为断档后首笔入账。" } },
|
||
{ date: "2026-07-22", company: "金牛物流", bank: "建行", account: "5562", acctLabel: "建行 · 尾号 5562", dir: "收", dirPill: "pill-success", peer: "河南金牛贸易有限公司", summary: "钢材干线运输费(6-7 月)", serial: "CCB202607220347815", status: "已归集", statusPill: "pill-success", amount: "462,800.00", amtClass: "amt-in", detail: { bank: "建设银行", account: "河南金牛物流有限公司 · 基本户 4105 0167 8080 5562", time: "2026-07-22 14:52:26", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 462,800.00(收)", status: "已归集", pair: "金牛物流 ↔ 金牛贸易", subject: "应收(物流侧)", batch: "JC-2026-07-021", note: "与贸易侧农行尾号 2208 账户 07-22 付款流水双向匹配,金额一致,已计入 7 月往来批次。" } },
|
||
{ date: "2026-07-24", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "组件辅材结算款", serial: "ICBC202607240221476", status: "单边", statusPill: "pill-danger", amount: "1,290,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-24 11:06:33", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,290,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-009", note: "贸易侧无对应收款记录,单边挂起。新能源↔贸易本月累计 3 笔单边流水,合计 486 万元。" } },
|
||
{ date: "2026-07-25", company: "金牛贸易", bank: "农行", account: "2208", acctLabel: "农行 · 尾号 2208", dir: "付", dirPill: "pill-danger", peer: "安阳钢铁集团有限责任公司", summary: "螺纹钢采购款(7 月)", serial: "ABC202607250819673", status: "未归集", statusPill: "pill-muted", amount: "5,620,000.00", amtClass: "amt-out", detail: { bank: "农业银行", account: "河南金牛贸易有限公司 · 基本户 1606 3301 0400 0220 8", time: "2026-07-25 10:19:05", peer: "安阳钢铁集团有限责任公司", peerAcct: "工行安阳分行 1706 0211 0900 4428 17", amount: "¥ 5,620,000.00(付)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外螺纹钢采购付款,不参与集团内部往来归集。" } },
|
||
];
|
||
|
||
// 原型占位,非本司真流水:COMPANY_FLOWS 仅用于演示公司端流水列表界面,
|
||
// 真实流水来自 /api/parse 导入与 /api/batches 批次,切勿把它当成真数据源。
|
||
const COMPANY_FLOWS = [
|
||
{ date: "2026-07-02", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "煤炭采购款(2026 年 6 月供煤合同结算)", serial: "ICBC2026070200185347", status: "已归集", statusPill: "pill-success", amount: "3,200,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-02 15:47:22", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 3,200,000.00(收)", status: "已归集", pair: "金牛煤业 ↔ 金牛置业", subject: "应收(煤业侧)", batch: "JH-202607-014", note: "与置业侧中行尾号 8821 账户付款流水已双向匹配。" } },
|
||
{ date: "2026-07-03", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "山西晋城王坡煤矿有限责任公司", summary: "原料煤采购预付款", serial: "ICBC2026070300221091", status: "未归集 · 外部", statusPill: "pill-muted", amount: "860,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-03 10:26:31", peer: "山西晋城王坡煤矿有限责任公司", peerAcct: "工行晋城分行 1702 0218 0902 6641 20", amount: "¥ 860,000.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对方为集团外供应商,不参与内部往来归集,仅作银行流水留档。" } },
|
||
{ date: "2026-07-06", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "河南神火运销有限公司", summary: "动力煤销售货款(7 月第一批)", serial: "BOC2026070600772018", status: "未归集 · 外部", statusPill: "pill-muted", amount: "1,246,800.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-06 09:58:44", peer: "河南神火运销有限公司", peerAcct: "工行永城分行 1702 0218 0902 9075 63", amount: "¥ 1,246,800.00(收)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外动力煤销售回款,不参与集团内部往来归集。" } },
|
||
{ date: "2026-07-08", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "河南金牛物流有限公司", summary: "6 月煤炭公路运输费结算", serial: "ICBC2026070800311276", status: "已归集", statusPill: "pill-success", amount: "486,500.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-08 11:15:09", peer: "河南金牛物流有限公司", peerAcct: "建行郑州经开区支行 4105 0167 8080 5562", amount: "¥ 486,500.00(付)", status: "已归集", pair: "金牛煤业 ↔ 金牛物流", subject: "应付(煤业侧)", batch: "JH-202607-014", note: "与物流侧建行收款流水已双向匹配,运单 42 张随附。" } },
|
||
{ date: "2026-07-10", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "选煤设备配件代购款", serial: "BOC2026071000819455", status: "待确认", statusPill: "pill-warn", amount: "214,700.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-10 14:08:52", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 214,700.00(付)", status: "待确认", pair: "金牛煤业 ↔ 金牛贸易", subject: "其他应付(待复核)", batch: "JH-202607-021", note: "贸易侧已确认收款,科目待双方复核(应付 / 其他应付)。" } },
|
||
{ date: "2026-07-13", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "国网河南省电力公司新密市供电公司", summary: "7 月工业电费", serial: "ICBC2026071300458820", status: "未归集 · 外部", statusPill: "pill-muted", amount: "1,528,300.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-13 10:19:05", peer: "国网河南省电力公司新密市供电公司", peerAcct: "工行新密支行 1706 0211 0900 4428 17", amount: "¥ 1,528,300.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外电费支出,不参与集团内部往来归集。" } },
|
||
{ date: "2026-07-15", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "河南金牛新能源有限公司", summary: "场区租赁费返还(二季度)", serial: "BOC2026071500923314", status: "单边", statusPill: "pill-danger", amount: "95,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-15 11:06:33", peer: "河南金牛新能源有限公司", peerAcct: "工行郑州分行 1704 0512 0900 2266 49", amount: "¥ 95,000.00(收)", status: "单边", pair: "金牛煤业 ↔ 金牛新能源", subject: "其他应收(煤业侧)", batch: "—(待归集)", note: "新能源侧尚未提报对应付款流水,形成单边。" } },
|
||
{ date: "2026-07-18", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "国家税务总局新密市税务局", summary: "增值税及附加税费(6 月属期)", serial: "ICBC2026071800506639", status: "未归集 · 外部", statusPill: "pill-muted", amount: "2,073,450.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-18 13:29:40", peer: "国家税务总局新密市税务局", peerAcct: "国库专户", amount: "¥ 2,073,450.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "税费缴库,不参与集团内部往来归集。" } },
|
||
{ date: "2026-07-21", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "永城煤电控股集团有限公司", summary: "块煤销售款(年度长协第 7 批)", serial: "BOC2026072101054472", status: "未归集 · 外部", statusPill: "pill-muted", amount: "3,864,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-21 09:35:12", peer: "永城煤电控股集团有限公司", peerAcct: "工行永城分行 1702 0218 0902 3477 12", amount: "¥ 3,864,000.00(收)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外块煤销售回款,不参与集团内部往来归集。" } },
|
||
{ date: "2026-07-24", company: "金牛煤业", bank: "交通银行", account: "7710", acctLabel: "交行 · 尾号 7710", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "临时往来款归还", serial: "BCM2026072400088116", status: "待确认", statusPill: "pill-warn", amount: "1,500,000.00", amtClass: "amt-in", detail: { bank: "交通银行", account: "河南金牛煤业有限公司 · 一般户 4110 6120 0181 0077 10(账户待审核)", time: "2026-07-24 14:52:26", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 1,500,000.00(收)", status: "待确认", pair: "金牛煤业 ↔ 金牛置业", subject: "其他应收(煤业侧)", batch: "JH-202607-021", note: "交行 7710 账户尚处待审核,归集结果以账户审核通过后为准。" } },
|
||
{ date: "2026-07-27", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "煤业职工工资代发(2026 年 7 月)", summary: "7 月职工工资及奖金代发,共 612 人", serial: "ICBC2026072700582241", status: "未归集 · 外部", statusPill: "pill-muted", amount: "2,416,780.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-27 10:11:57", peer: "煤业职工工资代发(2026 年 7 月)", peerAcct: "代发专户", amount: "¥ 2,416,780.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "工资代发,不参与集团内部往来归集。" } },
|
||
{ date: "2026-07-30", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "河南金牛农业科技发展有限公司", summary: "临时周转借款(约定 8 月归还)", serial: "BOC2026073001187903", status: "单边", statusPill: "pill-danger", amount: "800,000.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-30 16:32:08", peer: "河南金牛农业科技发展有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 800,000.00(付)", status: "单边", pair: "金牛煤业 ↔ 金牛农业", subject: "其他应收(煤业侧)", batch: "—(待归集)", note: "农业 7 月未提交流水,暂无对方侧证据。" } },
|
||
];
|
||
|
||
function currentFlowData() {
|
||
return portal === "admin" ? FLOW_DEMO : COMPANY_FLOWS;
|
||
}
|
||
|
||
function renderFlowRows() {
|
||
const tbody = $("#flowTable tbody");
|
||
if (!tbody) return;
|
||
const data = currentFlowData();
|
||
tbody.innerHTML = data.map((row, index) => {
|
||
const companyCell = portal === "admin" ? `<td class="cell-main">${row.company}</td>` : "";
|
||
return `<tr class="clickable" data-flow-idx="${index}" data-company="${row.company}" data-bank="${row.bank}" data-account="${row.account}" data-date="${row.date}" data-dir="${row.dir}">
|
||
<td class="num">${row.date}</td>
|
||
${companyCell}
|
||
<td>${row.acctLabel}</td>
|
||
<td><span class="pill ${row.dirPill}">${row.dir}</span></td>
|
||
<td class="wrap">${row.peer}</td>
|
||
<td class="wrap">${row.summary}</td>
|
||
<td class="num">${row.serial}</td>
|
||
<td><span class="pill ${row.statusPill}">${row.status}</span></td>
|
||
<td class="num-col ${row.amtClass}">¥ ${row.amount}</td>
|
||
</tr>`;
|
||
}).join("");
|
||
const count = $("#flowCount");
|
||
if (count) count.textContent = `共 ${data.length} 笔`;
|
||
const empty = $("#flowEmpty");
|
||
if (empty) empty.hidden = data.length > 0;
|
||
const sum = $("#flowSum");
|
||
if (sum) {
|
||
let inflow = 0, outflow = 0;
|
||
data.forEach((row) => { if (row.dir === "收") inflow += Number(row.amount.replace(/,/g, "")); else outflow += Number(row.amount.replace(/,/g, "")); });
|
||
sum.innerHTML = data.length ? `收 <span class="amt-in">+¥ ${formatCurrency(inflow)}</span> · 付 <span class="amt-out">-¥ ${formatCurrency(outflow)}</span>` : "";
|
||
}
|
||
}
|
||
|
||
function openFlowDetail(index) {
|
||
const modal = $("#tx-modal");
|
||
if (!modal) return;
|
||
const row = currentFlowData()[index];
|
||
if (!row) return;
|
||
$("#tx-modal-sub").textContent = `${row.company} · ${row.acctLabel} · ${row.date}`;
|
||
const d = row.detail;
|
||
$("#d-serial").textContent = row.serial;
|
||
$("#d-bank").textContent = d.bank;
|
||
$("#d-account").textContent = d.account;
|
||
$("#d-time").textContent = d.time;
|
||
$("#d-peer").textContent = d.peer;
|
||
$("#d-peer-acct").textContent = d.peerAcct;
|
||
$("#d-amount").textContent = d.amount;
|
||
$("#d-status").textContent = d.status;
|
||
$("#d-pair").textContent = d.pair;
|
||
$("#d-subject").textContent = d.subject;
|
||
$("#d-batch").textContent = d.batch;
|
||
$("#d-note").textContent = d.note;
|
||
modal.classList.add("open");
|
||
}
|
||
|
||
function visibleRows(table) {
|
||
return $$('tbody tr', table).filter((row) => !row.hidden);
|
||
}
|
||
|
||
function filterFlows() {
|
||
const table = $("#flowTable");
|
||
if (!table) return;
|
||
const company = $("#flowCompany")?.value || "全部公司";
|
||
const bank = $("#flowBank")?.value || "全部银行";
|
||
const account = $("#flowAccount")?.value || "全部账户";
|
||
const startDate = $("#flowStart")?.value || "0000-01-01";
|
||
const endDate = $("#flowEnd")?.value || "9999-12-31";
|
||
const keyword = $("#flowKeyword")?.value.trim().toLowerCase() || "";
|
||
if (startDate > endDate) {
|
||
showToast("日期范围无效", "开始日期不能晚于结束日期", "warn");
|
||
return;
|
||
}
|
||
let count = 0;
|
||
$$("tbody tr", table).forEach((row) => {
|
||
const rowDate = row.dataset.date || $("td", row).textContent.trim().replaceAll(".", "-");
|
||
const matchesCompany = company === "全部公司" || row.dataset.company === company;
|
||
const matchesBank = bank === "全部银行" || row.dataset.bank === bank;
|
||
const matchesAccount = account === "全部账户" || row.dataset.account === account || (row.dataset.account === undefined && row.textContent.includes(account));
|
||
const matchesDate = rowDate >= startDate && rowDate <= endDate;
|
||
const matchesKeyword = !keyword || row.textContent.toLowerCase().includes(keyword);
|
||
row.hidden = !(matchesCompany && matchesBank && matchesAccount && matchesDate && matchesKeyword);
|
||
if (!row.hidden) count += 1;
|
||
});
|
||
$("#flowCount").textContent = count === 0 ? "共 0 条 · 无匹配记录" : `共 ${count} 笔 · 本页 1-${count}`;
|
||
const empty = $("#flowEmpty");
|
||
if (empty) empty.hidden = count > 0;
|
||
const sum = $("#flowSum");
|
||
if (sum) {
|
||
let inflow = 0, outflow = 0;
|
||
$$("tbody tr", table).forEach((row) => {
|
||
if (row.hidden) return;
|
||
const amount = Number(($("td:last-child", row)?.textContent || "").replace(/[^\d.]/g, ""));
|
||
if (row.dataset.dir === "收") inflow += amount; else outflow += amount;
|
||
});
|
||
sum.innerHTML = count ? `收 <span class="amt-in">+¥ ${formatCurrency(inflow)}</span> · 付 <span class="amt-out">-¥ ${formatCurrency(outflow)}</span>` : "";
|
||
}
|
||
showToast("查询完成", `当前显示 ${count} 笔流水`);
|
||
}
|
||
|
||
function exportFlows() {
|
||
const table = $("#flowTable");
|
||
const rows = visibleRows(table);
|
||
const headers = ["交易日期", "公司", "银行账户", "方向", "对方户名", "摘要", "银行流水号", "归集状态", "金额", "导入批次", "源行定位"];
|
||
const records = rows.map((row, index) => {
|
||
const cells = $$('td', row).map((cell) => cell.innerText.replace(/\n/g, " ").trim());
|
||
const imp = `IMP-DEMO-${String(index + 1).padStart(3, "0")}`;
|
||
const loc = `Sheet1!R${index + 8}`;
|
||
const companyName = state.me?.company_name || "本公司";
|
||
if (portal === "admin") return [...cells.slice(0, 9), imp, loc];
|
||
return [cells[0], companyName, cells[1], ...cells.slice(2), imp, loc];
|
||
});
|
||
const csv = [headers, ...records].map((record) => record.map((value) => `"${String(value ?? "").replace(/"/g, '""')}"`).join(",")).join("\r\n");
|
||
const link = document.createElement("a");
|
||
link.href = URL.createObjectURL(new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" }));
|
||
link.download = `${portal === "admin" ? "集团" : (state.me?.company_name || "本公司")}银行流水_202607.csv`;
|
||
link.click();
|
||
URL.revokeObjectURL(link.href);
|
||
showToast("导出已生成", `共 ${rows.length} 笔,已保留银行标识与源行定位`, "success");
|
||
}
|
||
|
||
function initFlowTools() {
|
||
$("#applyFlowFilters")?.addEventListener("click", filterFlows);
|
||
$("#exportFlows")?.addEventListener("click", exportFlows);
|
||
renderFlowRows();
|
||
$("#resetFlowFilters")?.addEventListener("click", () => {
|
||
const company = $("#flowCompany");
|
||
if (company) company.value = "全部公司";
|
||
$("#flowBank").value = "全部银行";
|
||
$("#flowAccount").value = "全部账户";
|
||
$("#flowStart").value = "2026-07-01";
|
||
$("#flowEnd").value = portal === "admin" ? "2026-07-31" : "2026-08-20";
|
||
$("#flowKeyword").value = "";
|
||
filterFlows();
|
||
});
|
||
$("#flowTable tbody")?.addEventListener("click", (event) => {
|
||
const row = event.target.closest("tr[data-flow-idx]");
|
||
if (row) openFlowDetail(Number(row.dataset.flowIdx));
|
||
});
|
||
$("#tx-modal-close")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open"));
|
||
$("#tx-modal-ok")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open"));
|
||
$("#tx-modal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); });
|
||
}
|
||
|
||
function resetUpload() {
|
||
state.selectedFile = null;
|
||
state.parseResult = null;
|
||
$("#uploadForm")?.reset();
|
||
if ($("#filePreview")) $("#filePreview").hidden = true;
|
||
if ($("#parseResult")) $("#parseResult").hidden = true;
|
||
if ($("#sheetReview")) $("#sheetReview").hidden = true;
|
||
if ($("#sheetList")) $("#sheetList").replaceChildren();
|
||
if ($("#dropzone")) $("#dropzone").hidden = false;
|
||
if ($("#parseButton")) {
|
||
$("#parseButton").disabled = true;
|
||
$("#parseButton span").textContent = "开始解析";
|
||
delete $("#parseButton").dataset.stage;
|
||
}
|
||
}
|
||
|
||
function updateParseButton() {
|
||
const button = $("#parseButton");
|
||
if (button) button.disabled = !(state.selectedFile && $("#accountSelect").value);
|
||
}
|
||
|
||
function acceptFile(file) {
|
||
if (!file) return;
|
||
const extension = file.name.split(".").pop().toLowerCase();
|
||
if (!["xls", "xlsx"].includes(extension)) {
|
||
showToast("文件格式不支持", "请选择银行导出的 .xls 或 .xlsx 文件", "danger");
|
||
return;
|
||
}
|
||
state.selectedFile = file;
|
||
state.parseResult = null;
|
||
$("#fileName").textContent = file.name;
|
||
$("#fileMeta").textContent = `${(file.size / 1024).toFixed(1)} KB · 等待表头识别`;
|
||
$("#filePreview").hidden = false;
|
||
$("#dropzone").hidden = true;
|
||
$("#parseResult").hidden = true;
|
||
delete $("#parseButton").dataset.stage;
|
||
updateParseButton();
|
||
}
|
||
|
||
async function parseFile() {
|
||
const formData = new FormData();
|
||
formData.append("file", state.selectedFile);
|
||
const selectedAccount = $("#accountSelect")?.selectedOptions?.[0];
|
||
if (selectedAccount?.dataset.accountId) {
|
||
formData.append("bank_account_id", selectedAccount.dataset.accountId);
|
||
}
|
||
let result;
|
||
let parsed = false;
|
||
try {
|
||
const response = await fetch("/api/parse", { method: "POST", body: formData });
|
||
if (response.status === 401 || response.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
result = await response.json();
|
||
parsed = response.ok && ["parsed", "duplicate"].includes(result.status);
|
||
} catch {
|
||
result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" };
|
||
}
|
||
state.parseResult = result;
|
||
renderParseResult(result, parsed);
|
||
}
|
||
|
||
function renderParseResult(result, parsed) {
|
||
const panel = $("#parseResult");
|
||
if (!panel) return;
|
||
const sheets = Array.isArray(result.sheets) ? result.sheets : [];
|
||
const duplicated = result.status === "duplicate";
|
||
const opaque = duplicated && !sheets.length;
|
||
panel.classList.toggle("info", parsed);
|
||
panel.classList.toggle("warn", !parsed);
|
||
const parseIcon = $("use", panel);
|
||
if (parseIcon) parseIcon.setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
|
||
$("#parseTitle").textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
|
||
const pendingCount = sheets.filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
|
||
const exceptionCount = sheets.filter((s) => s.outcome === "exception").length;
|
||
const ignoredCount = sheets.filter((s) => s.outcome === "ignored" || s.review_status === "ignored").length;
|
||
let summary;
|
||
if (opaque) {
|
||
summary = "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。";
|
||
} else if (sheets.length) {
|
||
summary = `${sheets.length} 个工作表:${pendingCount} 个待确认${exceptionCount ? `、${exceptionCount} 个异常` : ""}${ignoredCount ? `、${ignoredCount} 个忽略` : ""}。解析成功不等于业务确认。`;
|
||
} else {
|
||
summary = `${result.message || ""} 系统不会猜测模板或自动入账。`;
|
||
}
|
||
$("#parseSummary").textContent = summary;
|
||
panel.hidden = false;
|
||
renderSheetList(sheets, result.batch_id);
|
||
|
||
const button = $("#parseButton");
|
||
button.dataset.stage = "confirm";
|
||
button.disabled = false;
|
||
if (!parsed) {
|
||
button.querySelector("span").textContent = "关闭";
|
||
} else if (opaque) {
|
||
button.querySelector("span").textContent = "完成";
|
||
} else if (sheets.length) {
|
||
button.querySelector("span").textContent = pendingCount ? `确认全部(${pendingCount} 个待确认)` : "完成";
|
||
} else {
|
||
button.querySelector("span").textContent = "完成";
|
||
}
|
||
}
|
||
|
||
function sheetStatusMeta(sheet) {
|
||
if (sheet.review_status === "confirmed") return { className: "success", label: "已确认" };
|
||
if (sheet.review_status === "ignored") return { className: "neutral", label: "已忽略" };
|
||
if (sheet.outcome === "exception") return { className: "danger", label: "异常待处理" };
|
||
if (sheet.outcome === "ignored") return { className: "neutral", label: "空表忽略" };
|
||
return { className: "warning", label: "待确认" };
|
||
}
|
||
|
||
function renderSheetList(sheets, batchId) {
|
||
const wrap = $("#sheetReview");
|
||
const list = $("#sheetList");
|
||
if (!wrap || !list || !sheets.length) {
|
||
if (wrap) wrap.hidden = true;
|
||
return;
|
||
}
|
||
wrap.hidden = false;
|
||
list.replaceChildren(...sheets.map((sheet) => buildSheetItem(sheet, batchId)));
|
||
}
|
||
|
||
function buildSheetItem(sheet, batchId) {
|
||
const item = document.createElement("div");
|
||
item.className = "list-row";
|
||
const meta = sheetStatusMeta(sheet);
|
||
const main = document.createElement("div");
|
||
main.className = "lr-main";
|
||
const title = document.createElement("div");
|
||
title.className = "lr-title";
|
||
title.textContent = sheet.sheet_name;
|
||
main.append(title);
|
||
const details = document.createElement("div");
|
||
details.className = "lr-sub";
|
||
if (sheet.outcome === "parsed" && sheet.bank) {
|
||
const period = sheet.period_start ? ` · ${sheet.period_start}—${sheet.period_end}` : "";
|
||
details.textContent = `${sheet.bank} · ${sheet.transactions} 条明细${period}`;
|
||
} else if (sheet.message) {
|
||
details.textContent = sheet.message;
|
||
} else {
|
||
details.textContent = "空工作表。";
|
||
}
|
||
if (sheet.review_reason) {
|
||
details.textContent += ` · 原因:${sheet.review_reason}`;
|
||
}
|
||
main.append(details);
|
||
|
||
const badge = document.createElement("span");
|
||
badge.className = `pill ${pillClass(meta.className)}`;
|
||
badge.textContent = meta.label;
|
||
|
||
item.append(main, badge);
|
||
|
||
if (sheet.review_status === "pending") {
|
||
const actions = document.createElement("div");
|
||
actions.className = "lr-side";
|
||
actions.style.cssText = "display:flex;gap:6px;flex:none;";
|
||
if (sheet.outcome === "parsed") {
|
||
const confirmButton = document.createElement("button");
|
||
confirmButton.type = "button";
|
||
confirmButton.className = "btn btn-sm btn-primary";
|
||
confirmButton.textContent = "确认";
|
||
confirmButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "confirm"));
|
||
actions.append(confirmButton);
|
||
}
|
||
const ignoreButton = document.createElement("button");
|
||
ignoreButton.type = "button";
|
||
ignoreButton.className = "btn btn-sm";
|
||
ignoreButton.textContent = "忽略";
|
||
ignoreButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "ignore"));
|
||
actions.append(ignoreButton);
|
||
item.append(actions);
|
||
}
|
||
return item;
|
||
}
|
||
|
||
async function sheetReviewAction(batchId, sheetName, decision) {
|
||
const payload = { sheets: [sheetName] };
|
||
if (decision === "ignore") {
|
||
const reason = (window.prompt("请填写忽略原因(必填):", "") || "").trim();
|
||
if (!reason) {
|
||
showToast("忽略未提交", "必须填写忽略原因", "warn");
|
||
return;
|
||
}
|
||
payload.reason = reason;
|
||
}
|
||
const response = await fetch(`/api/batches/${batchId}/${decision}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
}).catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
showToast("操作失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
showToast(decision === "confirm" ? "工作表已确认" : "工作表已忽略", `${sheetName}`, "success");
|
||
await refreshAfterSheetAction(result, batchId);
|
||
}
|
||
|
||
async function refreshAfterSheetAction(result, batchId) {
|
||
if (Array.isArray(result.sheets)) renderSheetList(result.sheets, batchId);
|
||
await loadImportBatches();
|
||
const button = $("#parseButton");
|
||
if (!button) return;
|
||
const pending = (result.sheets || []).filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
|
||
if (pending === 0 && (result.sheets || []).length) {
|
||
button.querySelector("span").textContent = "完成";
|
||
button.dataset.stage = "done";
|
||
} else {
|
||
button.querySelector("span").textContent = `确认全部(${pending} 个待确认)`;
|
||
}
|
||
}
|
||
|
||
async function confirmImport() {
|
||
const result = state.parseResult;
|
||
const batchId = result?.batch_id;
|
||
const sheets = Array.isArray(result?.sheets) ? result.sheets : [];
|
||
const pending = sheets
|
||
.filter((s) => s.outcome === "parsed" && s.review_status === "pending")
|
||
.map((s) => s.sheet_name);
|
||
if (!pending.length) {
|
||
resetUpload();
|
||
await loadImportBatches();
|
||
showView("upload");
|
||
return;
|
||
}
|
||
const button = $("#parseButton");
|
||
button.disabled = true;
|
||
button.querySelector("span").textContent = "正在确认...";
|
||
const response = await fetch(`/api/batches/${batchId}/confirm`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ sheets: pending }),
|
||
}).catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const outcome = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
button.disabled = false;
|
||
button.querySelector("span").textContent = "确认失败,点击重试";
|
||
showToast("确认失败", outcome?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
button.disabled = false;
|
||
if (Array.isArray(outcome.sheets)) renderSheetList(outcome.sheets, batchId);
|
||
await loadImportBatches();
|
||
resetUpload();
|
||
showView("upload");
|
||
showToast("流水已确认", `${outcome.updated.length} 个工作表已确认;未确认的工作表不参与计算`, "success");
|
||
}
|
||
|
||
function submitImportException() {
|
||
resetUpload();
|
||
showView("upload");
|
||
showToast("解析异常未入账", `${state.selectedFile?.name || "该文件"} 不会进入匹配与计算,请核对模板后重新导出`, "warn");
|
||
}
|
||
|
||
function renderBatchRow(batch) {
|
||
const row = document.createElement("tr");
|
||
row.dataset.batchId = batch.id;
|
||
const idCell = document.createElement("td");
|
||
const id = document.createElement("span"); id.className = "cell-main num"; id.textContent = `IMP-${String(batch.id).padStart(6, "0")}`;
|
||
const file = document.createElement("span"); file.className = "cell-sub"; file.textContent = batch.original_filename || "";
|
||
idCell.append(id, file);
|
||
|
||
const bank = document.createElement("td");
|
||
bank.textContent = batch.bank_name || "—";
|
||
|
||
const period = document.createElement("td");
|
||
period.className = "num";
|
||
period.textContent = batch.period_start && batch.period_end
|
||
? `${batch.period_start} ~ ${batch.period_end}`
|
||
: "—";
|
||
|
||
const count = document.createElement("td");
|
||
count.className = "num-col";
|
||
count.textContent = `${batch.confirmed_transactions ?? 0}`;
|
||
|
||
const coverage = document.createElement("td");
|
||
const coverageStatus = batch.status === "exception"
|
||
? { className: "danger", label: "未导入" }
|
||
: batch.pending_sheets > 0
|
||
? { className: "warning", label: `${batch.pending_sheets} 个待确认` }
|
||
: batch.confirmed_sheets > 0
|
||
? { className: "success", label: "已确认" }
|
||
: { className: "neutral", label: "待处理" };
|
||
coverage.innerHTML = `<span class="pill ${pillClass(coverageStatus.className)}">${coverageStatus.label}</span>`;
|
||
|
||
const parseState = document.createElement("td");
|
||
const statusParts = [];
|
||
if (batch.exception_sheets > 0) statusParts.push(`${batch.exception_sheets} 个异常`);
|
||
if (batch.ignored_sheets > 0) statusParts.push(`${batch.ignored_sheets} 个忽略`);
|
||
if (statusParts.length) {
|
||
parseState.innerHTML = `<span class="pill pill-warn">${statusParts.join("、")}</span>`;
|
||
} else {
|
||
parseState.innerHTML = '<span class="pill pill-success">解析成功</span>';
|
||
}
|
||
|
||
const time = document.createElement("td");
|
||
time.className = "meta";
|
||
time.textContent = String(batch.created_at || "").slice(0, 16).replace("T", " ");
|
||
|
||
const action = document.createElement("td");
|
||
action.innerHTML = '<button type="button" class="btn btn-sm" data-batch-view>查看</button>';
|
||
|
||
row.append(idCell, bank, period, count, coverage, parseState, time, action);
|
||
return row;
|
||
}
|
||
|
||
async function loadImportBatches() {
|
||
const tbody = $("#importRows");
|
||
if (!tbody) return;
|
||
showTableLoading(tbody, 8);
|
||
const response = await fetch("/api/batches").catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
if (!response?.ok) {
|
||
showTableError(tbody, 8);
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
const batches = Array.isArray(result?.batches) ? result.batches : [];
|
||
state.batches = batches;
|
||
tbody.replaceChildren(...batches.map(renderBatchRow));
|
||
const foot = $("#importFoot");
|
||
if (foot) foot.textContent = batches.length ? `共 ${batches.length} 个批次` : "暂无批次";
|
||
}
|
||
|
||
function formatWorkspaceAmount(amount, currency) {
|
||
const n = Number(amount);
|
||
const text = Number.isFinite(n)
|
||
? n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
: String(amount ?? "—");
|
||
return `${currency || "CNY"} ${text}`;
|
||
}
|
||
|
||
function applyCompanyWorkspace(payload) {
|
||
const events = Array.isArray(payload?.unilateral_events) ? payload.unilateral_events : [];
|
||
const pending = Number(payload?.pending_unilateral ?? events.length) || 0;
|
||
const total = Number(payload?.pending_total ?? pending) || 0;
|
||
state.workspace = { pending_unilateral: pending, pending_total: total, unilateral_events: events };
|
||
|
||
const cta = $("#workspaceUnilateralCta");
|
||
if (cta) {
|
||
cta.textContent = pending ? `去确认单边流水 (${pending})` : "单边流水已全部确认";
|
||
cta.classList.toggle("btn-primary", pending > 0);
|
||
}
|
||
|
||
const status = $("#workspacePendingStatus");
|
||
if (status) {
|
||
status.textContent = total ? `${total} 项待处理` : "已完成";
|
||
status.className = `pill ${total ? "pill-warn" : "pill-success"}`;
|
||
}
|
||
|
||
const sub = $("#workspaceTodoSub");
|
||
if (sub) sub.textContent = total ? `权威待确认单边流水 ${pending} 笔` : "本月单边流水待办已清空";
|
||
|
||
const list = $("#workspaceTodoList");
|
||
if (list) {
|
||
list.replaceChildren();
|
||
if (!events.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "empty";
|
||
empty.style.padding = "18px 16px";
|
||
empty.innerHTML = '<div class="e-title">暂无待确认单边流水</div><div>刷新或重登后仍以服务端权威状态为准</div>';
|
||
list.append(empty);
|
||
} else {
|
||
events.forEach((event) => {
|
||
const row = document.createElement("div");
|
||
row.className = "list-row";
|
||
row.dataset.taskType = "match";
|
||
row.dataset.eventId = String(event.event_id);
|
||
row.innerHTML = `
|
||
<span class="pill pill-danger">阻断</span>
|
||
<div class="lr-main">
|
||
<div class="lr-title">确认单边流水 · ${formatWorkspaceAmount(event.amount, event.currency)}</div>
|
||
<div class="lr-sub">${event.counterparty_company_name || "对方待指定"} · ${event.effective_at || "—"}</div>
|
||
</div>
|
||
<button class="btn btn-sm" data-view-link="reconcile">去确认</button>`;
|
||
list.append(row);
|
||
});
|
||
}
|
||
}
|
||
|
||
const foot = $("#workspaceTodoFoot");
|
||
if (foot) {
|
||
foot.innerHTML = total
|
||
? `<span>处理完 ${total} 笔单边流水后,工作台数字与列表将同步归零</span>`
|
||
: "<span>单边流水待办已完成</span>";
|
||
}
|
||
|
||
const flowState = $("#workspaceConfirmState");
|
||
if (flowState) flowState.textContent = pending ? `待处理 ${pending} 笔` : "已完成";
|
||
const flowMeta = $("#workspaceConfirmMeta");
|
||
if (flowMeta) {
|
||
flowMeta.textContent = pending
|
||
? `单边流水 ${pending} 笔待确认`
|
||
: "已全部确认,等待集团结账";
|
||
}
|
||
// 完成态必须切到 success 绿(.flow-step.done),待确认保留 warn 黄(.doing)
|
||
const flowStep = flowState?.closest(".flow-step");
|
||
if (flowStep) {
|
||
flowStep.classList.toggle("doing", pending > 0);
|
||
flowStep.classList.toggle("done", pending === 0);
|
||
}
|
||
const flowSub = $("#workspaceFlowSub");
|
||
if (flowSub) {
|
||
flowSub.textContent = pending
|
||
? "当前停在第 3 步「往来确认」,完成后即可等待集团结账"
|
||
: "第 3 步「往来确认」已完成,等待集团复核与结账";
|
||
}
|
||
|
||
const countMatch = $("#count-match");
|
||
if (countMatch) countMatch.textContent = String(pending);
|
||
|
||
const noticeTitle = $("#notice-title");
|
||
const noticeBody = $("#notice-body");
|
||
const notice = $("#blocking-notice");
|
||
if (noticeTitle) {
|
||
noticeTitle.textContent = pending
|
||
? `${pending} 项单边流水待确认,是结账阻断项`
|
||
: "单边流水已全部确认完成";
|
||
}
|
||
if (noticeBody) {
|
||
noticeBody.textContent = pending
|
||
? `工作台「去确认单边流水」与「本月待办」均读取同一权威集合(${pending} 笔)。确认成功后立即同步减一。`
|
||
: "本公司单边流水待办已清空;刷新或重新登录后仍为 0。";
|
||
}
|
||
if (notice) {
|
||
notice.classList.toggle("warn", pending > 0);
|
||
notice.classList.toggle("success", pending === 0);
|
||
}
|
||
|
||
const badge = $('.side-nav a[data-view="reconcile"] .nav-badge');
|
||
if (badge) {
|
||
badge.textContent = pending;
|
||
badge.style.display = pending ? "" : "none";
|
||
}
|
||
}
|
||
|
||
async function loadCompanyWorkspace() {
|
||
const response = await fetch("/api/company/workspace").catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return null;
|
||
}
|
||
const result = await response?.json().catch(() => null);
|
||
if (!response?.ok || !result || result.status !== "ok") {
|
||
applyCompanyWorkspace({ pending_unilateral: 0, pending_total: 0, unilateral_events: [] });
|
||
showToast("工作台待办读取失败", result?.message || "请稍后重试", "danger");
|
||
return null;
|
||
}
|
||
applyCompanyWorkspace(result);
|
||
return result;
|
||
}
|
||
|
||
function renderUnilateralMatchCard(event, peers) {
|
||
const card = document.createElement("div");
|
||
card.className = "card";
|
||
card.dataset.matchCard = "true";
|
||
card.dataset.eventId = String(event.event_id);
|
||
card.dataset.revision = String(event.revision ?? "");
|
||
const amountText = formatWorkspaceAmount(event.amount, event.currency);
|
||
const peerName = event.counterparty_company_name || "对方待指定";
|
||
const options = peers
|
||
.filter((p) => Number(p.id) !== Number(event.own_company_id))
|
||
.map((p) => {
|
||
const selected = Number(p.id) === Number(event.counterparty_company_id) ? " selected" : "";
|
||
return `<option value="${p.id}"${selected}>${p.name}</option>`;
|
||
})
|
||
.join("");
|
||
card.innerHTML = `
|
||
<div class="card-head">
|
||
<span class="card-title">${event.effective_at || "—"} · <span class="num">${amountText}</span>
|
||
<span class="sub">对方:${peerName}</span></span>
|
||
<span class="pill pill-danger">单边</span>
|
||
</div>
|
||
<div class="detail-box" style="margin-bottom: 12px;">
|
||
<dl class="kv">
|
||
<dt>事件编号</dt><dd class="num">${event.event_id}</dd>
|
||
<dt>状态</dt><dd>${event.status || event.classification || "待确认"}</dd>
|
||
<dt>金额</dt><dd class="num">${amountText}</dd>
|
||
</dl>
|
||
</div>
|
||
<div class="field" style="margin-bottom: 14px;">
|
||
<label>确认对方公司</label>
|
||
<select class="select" data-counterparty-select>
|
||
<option value="">请选择对方公司</option>
|
||
${options}
|
||
</select>
|
||
<span class="hint">确认后按现有单边确认规则锁定对方参与方;工作台数字以服务端权威状态重拉。</span>
|
||
</div>
|
||
<div class="row">
|
||
<button class="btn btn-primary" data-match-confirm disabled>确认匹配</button>
|
||
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
|
||
</div>`;
|
||
const select = $("[data-counterparty-select]", card);
|
||
const button = $("[data-match-confirm]", card);
|
||
const syncEnabled = () => {
|
||
if (button) button.disabled = !select?.value;
|
||
};
|
||
select?.addEventListener("change", syncEnabled);
|
||
syncEnabled();
|
||
button?.addEventListener("click", async () => {
|
||
if (!select?.value || button.dataset.busy === "1") return;
|
||
button.dataset.busy = "1";
|
||
button.disabled = true;
|
||
const requestKey = `company-confirm-${event.event_id}-${event.revision}-${select.value}`;
|
||
const response = await fetch(`/api/company/transfer-events/${event.event_id}/confirm`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
expected_revision: event.revision,
|
||
request_key: requestKey,
|
||
counterparty_company_id: Number(select.value),
|
||
reason: "公司端确认单边流水",
|
||
}),
|
||
}).catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response?.ok) {
|
||
button.dataset.busy = "0";
|
||
syncEnabled();
|
||
showToast("确认失败", result?.message || "请刷新后重试", "danger");
|
||
// 失败不得本地误减:重拉权威状态
|
||
await loadCompanyWorkspace();
|
||
await renderReconcileMatchStack();
|
||
return;
|
||
}
|
||
const matchedList = $("#matched-list");
|
||
const matchedSummary = $("#matched-summary");
|
||
if (matchedList) {
|
||
const row = document.createElement("div");
|
||
row.className = "list-row";
|
||
row.innerHTML = `<div class="lr-main"><div class="lr-title">${amountText} · 已确认对方 ${select.selectedOptions[0]?.textContent || ""}</div></div><span class="pill pill-success lr-side">已匹配</span>`;
|
||
matchedList.append(row);
|
||
if (matchedSummary) matchedSummary.style.display = "";
|
||
}
|
||
if (result.workspace) applyCompanyWorkspace(result.workspace);
|
||
else await loadCompanyWorkspace();
|
||
await renderReconcileMatchStack();
|
||
showToast("匹配已确认", "工作台待办已按权威状态同步", "success");
|
||
});
|
||
return card;
|
||
}
|
||
|
||
async function loadCompanyPeerOptions() {
|
||
if (Array.isArray(state.companyPeers) && state.companyPeers.length) return state.companyPeers;
|
||
const response = await fetch("/api/company/companies").catch(() => null);
|
||
const result = await response?.json().catch(() => null);
|
||
const peers = Array.isArray(result?.companies) ? result.companies : [];
|
||
state.companyPeers = peers;
|
||
return peers;
|
||
}
|
||
|
||
async function renderReconcileMatchStack() {
|
||
const stack = $("#match-stack");
|
||
if (!stack) return;
|
||
const events = state.workspace?.unilateral_events || [];
|
||
const peers = await loadCompanyPeerOptions();
|
||
stack.replaceChildren();
|
||
if (!events.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "empty";
|
||
empty.innerHTML = '<div class="e-title">单边流水已全部匹配</div><div>确认结果已同步至工作台权威待办</div>';
|
||
stack.append(empty);
|
||
return;
|
||
}
|
||
events.forEach((event) => stack.append(renderUnilateralMatchCard(event, peers)));
|
||
}
|
||
|
||
function initReconcile() {
|
||
const subjectRows = $$("[data-subject-row]");
|
||
let pendingSubject = subjectRows.length;
|
||
const countSubject = $("#count-subject");
|
||
if (countSubject) countSubject.textContent = pendingSubject;
|
||
|
||
$$(".subject-confirm-btn").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const row = button.closest("[data-subject-row]");
|
||
const select = $("select", row);
|
||
const subject = select.value;
|
||
select.disabled = true;
|
||
button.disabled = true;
|
||
button.textContent = "已确认";
|
||
const statusCell = $(".subject-status", row);
|
||
statusCell.innerHTML = `<span class="pill pill-success">已确认 · ${subject}</span>`;
|
||
pendingSubject -= 1;
|
||
if (countSubject) countSubject.textContent = pendingSubject;
|
||
showToast("科目已确认", "科目确认仍为页面演示,不计入工作台权威待办", "success");
|
||
});
|
||
});
|
||
|
||
const tabMatch = $("#tab-match");
|
||
const tabSubject = $("#tab-subject");
|
||
const panelMatch = $("#panel-match");
|
||
const panelSubject = $("#panel-subject");
|
||
function switchTab(which) {
|
||
tabMatch?.classList.toggle("active", which === "match");
|
||
tabSubject?.classList.toggle("active", which === "subject");
|
||
tabMatch?.setAttribute("aria-pressed", String(which === "match"));
|
||
tabSubject?.setAttribute("aria-pressed", String(which === "subject"));
|
||
if (panelMatch) panelMatch.style.display = which === "match" ? "" : "none";
|
||
if (panelSubject) panelSubject.style.display = which === "subject" ? "" : "none";
|
||
}
|
||
tabMatch?.addEventListener("click", () => switchTab("match"));
|
||
tabSubject?.addEventListener("click", () => switchTab("subject"));
|
||
}
|
||
|
||
function initNotifications() {
|
||
const tabs = $("#notice-tabs");
|
||
const list = $("#notice-list");
|
||
if (!tabs || !list) return;
|
||
const rows = $$(".list-row", list);
|
||
const emptyBox = $("#notice-empty");
|
||
let currentFilter = "all";
|
||
|
||
const STATUS_PILL = { unread: "pill-danger", doing: "pill-warn", done: "pill-success" };
|
||
const STATUS_LABEL = { unread: "未读", doing: "处理中", done: "已完成" };
|
||
|
||
function counts() {
|
||
const c = { all: rows.length, unread: 0, doing: 0, done: 0 };
|
||
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
|
||
return c;
|
||
}
|
||
|
||
function refreshCounts() {
|
||
const c = counts();
|
||
$("#count-all").textContent = c.all;
|
||
$("#count-unread").textContent = c.unread;
|
||
$("#count-doing").textContent = c.doing;
|
||
$("#count-done").textContent = c.done;
|
||
const navBadge = $('.side-nav a[data-view="notifications"] .nav-badge');
|
||
if (navBadge) {
|
||
navBadge.textContent = c.unread;
|
||
navBadge.style.display = c.unread ? "" : "none";
|
||
}
|
||
}
|
||
|
||
function applyFilter() {
|
||
let visible = 0;
|
||
rows.forEach((row) => {
|
||
const show = currentFilter === "all" || row.getAttribute("data-status") === currentFilter;
|
||
row.style.display = show ? "" : "none";
|
||
if (show) visible += 1;
|
||
});
|
||
emptyBox.style.display = visible === 0 ? "" : "none";
|
||
}
|
||
|
||
function setStatus(row, status) {
|
||
row.setAttribute("data-status", status);
|
||
const pill = $(".pill", row);
|
||
if (pill) {
|
||
pill.className = `pill ${STATUS_PILL[status]}`;
|
||
pill.textContent = STATUS_LABEL[status];
|
||
}
|
||
}
|
||
|
||
tabs.addEventListener("click", (event) => {
|
||
const btn = event.target.closest("button[data-filter]");
|
||
if (!btn) return;
|
||
$$("button", tabs).forEach((b) => { b.classList.remove("active"); b.setAttribute("aria-pressed", "false"); });
|
||
btn.classList.add("active");
|
||
btn.setAttribute("aria-pressed", "true");
|
||
currentFilter = btn.dataset.filter;
|
||
applyFilter();
|
||
});
|
||
|
||
list.addEventListener("click", (event) => {
|
||
const btn = event.target.closest(".btn-mark-read");
|
||
if (!btn) return;
|
||
const row = btn.closest(".list-row");
|
||
setStatus(row, "done");
|
||
btn.remove();
|
||
refreshCounts();
|
||
applyFilter();
|
||
});
|
||
|
||
$("#mark-all-read")?.addEventListener("click", () => {
|
||
rows.forEach((row) => {
|
||
if (row.getAttribute("data-status") !== "unread") return;
|
||
setStatus(row, "done");
|
||
$(".btn-mark-read", row)?.remove();
|
||
});
|
||
refreshCounts();
|
||
applyFilter();
|
||
showToast("通知已全部标为已读", "", "success");
|
||
});
|
||
|
||
refreshCounts();
|
||
applyFilter();
|
||
}
|
||
|
||
function openAccountDetail(account) {
|
||
const meta = companyAccountMeta(account.status);
|
||
$("#ad-title").textContent = `${account.bank_name || ""} · 尾号 ${accountTail(account.account_number_masked)}`;
|
||
$("#ad-sub").textContent = "本公司 · 登记账户明细";
|
||
$("#ad-bank").textContent = account.bank_name || "—";
|
||
$("#ad-tail").textContent = `尾号 ${accountTail(account.account_number_masked)}`;
|
||
$("#ad-type").textContent = account.account_type || "—";
|
||
$("#ad-branch").textContent = account.account_name || "—";
|
||
$("#ad-reg").textContent = String(account.created_at || "").slice(0, 10) || "—";
|
||
$("#ad-status").textContent = meta.status.label;
|
||
$("#ad-audit").textContent = meta.audit.label;
|
||
$("#ad-purpose").textContent = "—";
|
||
$("#ad-effective").textContent = account.effective_from || "待审核确定";
|
||
$("#ad-range").textContent = account.usable ? "尚未上传" : "—";
|
||
$("#ad-reason").textContent = account.status === "returned" && account.review_reason ? account.review_reason : "—";
|
||
$("#modal-account-detail")?.classList.add("open");
|
||
}
|
||
|
||
function yuanToWan(value) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return null;
|
||
return n / 10000;
|
||
}
|
||
|
||
function formatWanHtml(value, { signed = false } = {}) {
|
||
const wan = yuanToWan(value);
|
||
if (wan === null) return "—";
|
||
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
if (!signed) return `${abs}<span class="unit">万元</span>`;
|
||
const sign = wan > 0 ? "+" : wan < 0 ? "−" : "";
|
||
return `${sign}${abs}<span class="unit">万元</span>`;
|
||
}
|
||
|
||
function formatWanText(value, { signed = false } = {}) {
|
||
const wan = yuanToWan(value);
|
||
if (wan === null) return "—";
|
||
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
if (!signed) return `${abs} 万元`;
|
||
const sign = wan > 0 ? "+" : wan < 0 ? "−" : "";
|
||
return `${sign}${abs} 万元`;
|
||
}
|
||
|
||
function netDirectionMeta(netValue, netDirection) {
|
||
const wan = yuanToWan(netValue);
|
||
if (wan === null || wan === 0 || !netDirection) {
|
||
return { label: "持平", className: "flat", signedClass: "" };
|
||
}
|
||
if (netDirection === "receivable" || wan > 0) {
|
||
return { label: "应收", className: "recv", signedClass: "pos" };
|
||
}
|
||
return { label: "应付", className: "pay", signedClass: "neg" };
|
||
}
|
||
|
||
function netLabelForWindow(windowInfo) {
|
||
return windowInfo?.has_opening ? "期末净往来" : "期间净变动";
|
||
}
|
||
|
||
function showTransfersOverviewLayer() {
|
||
const overview = $("#transfersOverviewLayer");
|
||
const detail = $("#transfersDetailLayer");
|
||
if (overview) overview.hidden = false;
|
||
if (detail) detail.hidden = true;
|
||
}
|
||
|
||
function showTransfersDetailLayer() {
|
||
const overview = $("#transfersOverviewLayer");
|
||
const detail = $("#transfersDetailLayer");
|
||
if (overview) overview.hidden = true;
|
||
if (detail) detail.hidden = false;
|
||
}
|
||
|
||
function setTransfersUiState(mode) {
|
||
["transfersLoading", "transfersError", "transfersEmpty", "transfersData"].forEach((id) => {
|
||
const el = $(`#${id}`);
|
||
if (el) el.hidden = id !== mode;
|
||
});
|
||
}
|
||
|
||
function applyTransfersNavBadge(pendingCount) {
|
||
const badge = $("#transfersNavBadge") || $('.side-nav a[data-view="transfers"] .nav-badge');
|
||
if (!badge) return;
|
||
const n = Number(pendingCount) || 0;
|
||
badge.textContent = String(n);
|
||
badge.hidden = n <= 0;
|
||
if (n <= 0) badge.setAttribute("hidden", "");
|
||
else badge.removeAttribute("hidden");
|
||
}
|
||
|
||
function renderWorkspaceTransfersCard(summary) {
|
||
const sub = $("#workspaceTransfersSub");
|
||
if (!sub || !summary) return;
|
||
const win = summary.window || {};
|
||
const confirmed = summary.confirmed || {};
|
||
const pending = summary.pending || {};
|
||
const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction);
|
||
sub.textContent = `${win.start || "—"} 至 ${win.end || "—"} · 集团内公司间 · 单位:万元`;
|
||
const inflow = $("#wsTfIn");
|
||
const outflow = $("#wsTfOut");
|
||
const net = $("#wsTfNet");
|
||
const pendingEl = $("#wsTfPending");
|
||
const netLabel = $("#wsTfNetLabel");
|
||
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
|
||
if (outflow) {
|
||
outflow.innerHTML = formatWanHtml(
|
||
confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null,
|
||
{ signed: true },
|
||
);
|
||
// formatWanHtml with negative value already adds −; ensure unit
|
||
if (confirmed.outflow_total != null) {
|
||
const wan = yuanToWan(confirmed.outflow_total);
|
||
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
outflow.innerHTML = `−${abs}<span class="unit">万元</span>`;
|
||
}
|
||
}
|
||
if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`;
|
||
if (net) {
|
||
net.className = `ms-value ${netMeta.signedClass}`.trim();
|
||
net.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
|
||
}
|
||
if (pendingEl) {
|
||
const pCount = Number(pending.count) || 0;
|
||
pendingEl.innerHTML = pCount
|
||
? `${formatWanHtml(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
|
||
: `0.00<span class="unit">万元 · 0 笔</span>`;
|
||
}
|
||
}
|
||
|
||
async function loadTransfersSummary({ asOf } = {}) {
|
||
if (!$("#transfersOverviewLayer")) return null;
|
||
setTransfersUiState("transfersLoading");
|
||
const params = new URLSearchParams();
|
||
if (asOf) params.set("as_of", asOf);
|
||
const qs = params.toString();
|
||
const response = await fetch(`/api/company/intercompany/summary${qs ? `?${qs}` : ""}`).catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return null;
|
||
}
|
||
const result = await response?.json().catch(() => null);
|
||
if (!response?.ok || !result || result.status !== "ok") {
|
||
setTransfersUiState("transfersError");
|
||
const body = $("#transfersErrorBody");
|
||
if (body) body.textContent = result?.message || "服务器连接异常。为避免误读,本页不展示任何金额。";
|
||
return null;
|
||
}
|
||
state.transfersSummary = result;
|
||
applyTransfersNavBadge(result.pending?.count);
|
||
renderWorkspaceTransfersCard(result);
|
||
renderTransfersOverview(result);
|
||
return result;
|
||
}
|
||
|
||
function renderTransfersOverview(summary) {
|
||
const win = summary.window || {};
|
||
const confirmed = summary.confirmed || {};
|
||
const pending = summary.pending || {};
|
||
const cps = Array.isArray(summary.counterparties) ? summary.counterparties : [];
|
||
const ownName = summary.own_company?.name || "本公司";
|
||
const pageSub = $("#transfersPageSub");
|
||
if (pageSub) {
|
||
pageSub.textContent = `${ownName} · 统计区间 ${win.start || "—"} 至 ${win.end || "—"},仅含集团内公司间转账(HEL-169 口径)。单位:万元。`;
|
||
}
|
||
|
||
const goConfirm = $("#transfersGoConfirm");
|
||
const pCount = Number(pending.count) || 0;
|
||
if (goConfirm) {
|
||
goConfirm.hidden = pCount <= 0;
|
||
goConfirm.textContent = pCount ? `去确认待确认 ${pCount} 笔` : "去确认待确认";
|
||
}
|
||
|
||
const hasAny = cps.length > 0 || Number(confirmed.outflow_count || 0) > 0 || Number(confirmed.inflow_count || 0) > 0 || pCount > 0;
|
||
if (!hasAny) {
|
||
setTransfersUiState("transfersEmpty");
|
||
const emptyStats = $("#transfersEmptyStats");
|
||
if (emptyStats) {
|
||
emptyStats.innerHTML = `
|
||
<div class="card stat-card"><div class="stat-label">往来公司数</div><div class="stat-value">0<span class="unit">家</span></div><div class="stat-foot">${win.start || "—"} ~ ${win.end || "—"}</div></div>
|
||
<div class="card stat-card"><div class="stat-label">本期转入 · 流入</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>
|
||
<div class="card stat-card"><div class="stat-label">本期转出 · 流出</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>
|
||
<div class="card stat-card"><div class="stat-label">${netLabelForWindow(win)}</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
setTransfersUiState("transfersData");
|
||
const companies = $("#tfStatCompanies");
|
||
const inflow = $("#tfStatIn");
|
||
const outflow = $("#tfStatOut");
|
||
const net = $("#tfStatNet");
|
||
const netTitle = $("#tfStatNetTitle");
|
||
const netFoot = $("#tfStatNetFoot");
|
||
const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction);
|
||
if (companies) companies.innerHTML = `${cps.length}<span class="unit">家</span>`;
|
||
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
|
||
if (outflow) {
|
||
const wan = yuanToWan(confirmed.outflow_total);
|
||
const abs = wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
outflow.innerHTML = wan === null ? "—" : `−${abs}<span class="unit">万元</span>`;
|
||
}
|
||
if (netTitle) netTitle.textContent = netLabelForWindow(win);
|
||
if (net) {
|
||
const tag = netMeta.label !== "持平"
|
||
? ` <span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span>`
|
||
: "";
|
||
net.innerHTML = `${formatWanHtml(confirmed.net_change, { signed: true })}${tag}`;
|
||
}
|
||
if (netFoot) {
|
||
netFoot.textContent = win.has_opening
|
||
? "期末 = 期初 + 已确认转出 − 已确认转入"
|
||
: "起算日未就绪 · 展示期间净变动(转出 − 转入),不得当作期末余额";
|
||
}
|
||
|
||
const confirmedCount = (Number(confirmed.outflow_count) || 0) + (Number(confirmed.inflow_count) || 0);
|
||
const tfConfirmedCount = $("#tfConfirmedCount");
|
||
const tfConfirmedNet = $("#tfConfirmedNet");
|
||
const tfPendingCount = $("#tfPendingCount");
|
||
const tfPendingAmount = $("#tfPendingAmount");
|
||
if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount} 笔`;
|
||
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
|
||
const openingCard = $("#tfStatOpeningCard");
|
||
const endingCard = $("#tfStatEndingCard");
|
||
const openingEl = $("#tfStatOpening");
|
||
const endingEl = $("#tfStatEnding");
|
||
if (win.has_opening) {
|
||
if (openingCard) openingCard.hidden = false;
|
||
if (endingCard) endingCard.hidden = false;
|
||
if (openingEl) openingEl.innerHTML = formatWanHtml(win.opening, { signed: true });
|
||
if (endingEl) endingEl.innerHTML = formatWanHtml(win.ending, { signed: true });
|
||
} else {
|
||
if (openingCard) openingCard.hidden = true;
|
||
if (endingCard) endingCard.hidden = true;
|
||
}
|
||
|
||
if (tfPendingCount) tfPendingCount.textContent = `${pCount} 笔`;
|
||
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWanHtml(pending.amount_total || 0);
|
||
|
||
const tbody = $("#transfersCpBody");
|
||
const tfoot = $("#transfersCpFoot");
|
||
if (!tbody) return;
|
||
if (!cps.length) {
|
||
tbody.innerHTML = `<tr><td colspan="7"><div class="empty" style="border:0;padding:24px 0;"><div class="e-title">暂无对方公司汇总</div></div></td></tr>`;
|
||
if (tfoot) tfoot.innerHTML = "";
|
||
return;
|
||
}
|
||
tbody.innerHTML = cps.map((cp) => {
|
||
const meta = netDirectionMeta(cp.net, Number(cp.net) > 0 ? "receivable" : Number(cp.net) < 0 ? "payable" : null);
|
||
const pendingN = Number(cp.pending_count) || 0;
|
||
const pendingPill = pendingN
|
||
? `<span class="pill pill-warn">${pendingN} 笔待确认</span>`
|
||
: `<span class="pill pill-success">全部已确认</span>`;
|
||
const last = cp.last_effective_at ? String(cp.last_effective_at).slice(0, 10) : "—";
|
||
const inWan = yuanToWan(cp.confirmed_inflow);
|
||
const outWan = yuanToWan(cp.confirmed_outflow);
|
||
const netWan = yuanToWan(cp.net);
|
||
const fmtAbs = (wan) => wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
const inText = inWan === null ? "—" : `+${fmtAbs(inWan)}`;
|
||
const outText = outWan === null ? "—" : `−${fmtAbs(outWan)}`;
|
||
const netText = netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "−" : ""}${fmtAbs(netWan)}`;
|
||
return `<tr class="clickable" data-cp-id="${cp.company_id}" data-cp-name="${String(cp.company_name || "").replace(/"/g, """)}">
|
||
<td class="cell-main">${cp.company_name || "—"}</td>
|
||
<td class="num-col amt-in">${inText}</td>
|
||
<td class="num-col amt-out">${outText}</td>
|
||
<td class="num-col">${netText}</td>
|
||
<td><span class="xfer-dir-tag ${meta.className}">${meta.label}</span></td>
|
||
<td>${pendingPill}</td>
|
||
<td class="num">${last}</td>
|
||
</tr>`;
|
||
}).join("");
|
||
|
||
if (tfoot) {
|
||
const totalPending = cps.reduce((s, cp) => s + (Number(cp.pending_count) || 0), 0);
|
||
const inWan = yuanToWan(confirmed.inflow_total);
|
||
const outWan = yuanToWan(confirmed.outflow_total);
|
||
const netWan = yuanToWan(confirmed.net_change);
|
||
const fmtAbs = (wan) => wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
tfoot.innerHTML = `<tr>
|
||
<td>合计</td>
|
||
<td class="num-col amt-in">${inWan === null ? "—" : `+${fmtAbs(inWan)}`}</td>
|
||
<td class="num-col amt-out">${outWan === null ? "—" : `−${fmtAbs(outWan)}`}</td>
|
||
<td class="num-col">${netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "−" : ""}${fmtAbs(netWan)}`}</td>
|
||
<td><span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span></td>
|
||
<td>${totalPending ? `<span class="pill pill-warn">${totalPending} 笔待确认</span>` : `<span class="pill pill-success">全部已确认</span>`}</td>
|
||
<td></td>
|
||
</tr>`;
|
||
}
|
||
}
|
||
|
||
function transfersEventQueryParams({ includeCursor = false } = {}) {
|
||
const detail = state.transfersDetail || {};
|
||
const filters = detail.filters || {};
|
||
const params = new URLSearchParams();
|
||
if (detail.company_id) params.set("counterparty_id", String(detail.company_id));
|
||
if (filters.from) params.set("from", filters.from);
|
||
if (filters.to) params.set("to", filters.to);
|
||
if (filters.direction) params.set("direction", filters.direction);
|
||
if (filters.state) params.set("state", filters.state);
|
||
params.set("limit", "50");
|
||
if (includeCursor && detail.nextCursor) params.set("cursor", detail.nextCursor);
|
||
return params;
|
||
}
|
||
|
||
async function openTransfersDetail(companyId, companyName, { keepFilters = false } = {}) {
|
||
const summary = state.transfersSummary;
|
||
const win = summary?.window || {};
|
||
const existing = state.transfersDetail;
|
||
const filters = keepFilters && existing?.filters
|
||
? { ...existing.filters }
|
||
: {
|
||
from: win.start || "",
|
||
to: win.end || "",
|
||
direction: "",
|
||
state: "",
|
||
};
|
||
state.transfersDetail = {
|
||
company_id: Number(companyId),
|
||
company_name: companyName || "对方公司",
|
||
filters,
|
||
nextCursor: null,
|
||
events: [],
|
||
};
|
||
state.transfersKeepDetail = true;
|
||
showTransfersDetailLayer();
|
||
const title = $("#currentViewName");
|
||
if (title) title.textContent = `转账往来 / ${state.transfersDetail.company_name}`;
|
||
$("#transfersDetailTitle").textContent = state.transfersDetail.company_name;
|
||
$("#tfFilterFrom").value = filters.from || "";
|
||
$("#tfFilterTo").value = filters.to || "";
|
||
$("#tfFilterDirection").value = filters.direction || "";
|
||
$("#tfFilterState").value = filters.state || "";
|
||
|
||
const cp = (summary?.counterparties || []).find((c) => Number(c.company_id) === Number(companyId));
|
||
const pendingN = Number(cp?.pending_count) || 0;
|
||
$("#tfDetailCount").innerHTML = `—`;
|
||
$("#tfDetailCountFoot").textContent = pendingN ? `其中待确认 ${pendingN} 笔(汇总)` : "按筛选加载明细";
|
||
if (cp) {
|
||
$("#tfDetailIn").innerHTML = formatWanHtml(cp.confirmed_inflow, { signed: true });
|
||
const wan = yuanToWan(cp.confirmed_outflow);
|
||
$("#tfDetailOut").innerHTML = wan === null
|
||
? "—"
|
||
: `−${Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}<span class="unit">万元</span>`;
|
||
}
|
||
if (state.currentView !== "transfers") {
|
||
state.transfersKeepDetail = true;
|
||
showView("transfers");
|
||
}
|
||
await loadTransfersEvents({ reset: true });
|
||
}
|
||
|
||
async function loadTransfersEvents({ reset = false } = {}) {
|
||
if (!state.transfersDetail?.company_id) return;
|
||
const tbody = $("#transfersEventBody");
|
||
const empty = $("#transfersEventEmpty");
|
||
const foot = $("#transfersEventFoot");
|
||
const moreBtn = $("#transfersLoadMore");
|
||
if (reset) {
|
||
state.transfersDetail.nextCursor = null;
|
||
state.transfersDetail.events = [];
|
||
if (tbody) showTableLoading(tbody, 6);
|
||
if (empty) empty.hidden = true;
|
||
if (moreBtn) moreBtn.hidden = true;
|
||
}
|
||
const params = transfersEventQueryParams({ includeCursor: !reset && !!state.transfersDetail.nextCursor });
|
||
// Always need a distinguishing filter so HEL-176 path is used (counterparty_id is enough)
|
||
const response = await fetch(`/api/company/intercompany/events?${params}`).catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => null);
|
||
if (!response?.ok || !result || result.status !== "ok") {
|
||
if (tbody) showTableError(tbody, 6);
|
||
showToast("明细加载失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
const events = Array.isArray(result.events) ? result.events : [];
|
||
if (reset) state.transfersDetail.events = events;
|
||
else state.transfersDetail.events = [...(state.transfersDetail.events || []), ...events];
|
||
state.transfersDetail.nextCursor = result.next_cursor || null;
|
||
state.transfersDetail.hasMore = !!result.has_more;
|
||
renderTransfersEvents();
|
||
if (foot) foot.textContent = `已加载 ${state.transfersDetail.events.length} 笔${result.has_more ? " · 还有更多" : ""}`;
|
||
if (moreBtn) moreBtn.hidden = !result.has_more;
|
||
$("#tfDetailCount").innerHTML = `${state.transfersDetail.events.length}${result.has_more ? "+" : ""}<span class="unit">笔</span>`;
|
||
}
|
||
|
||
function transfersStateLabel(event) {
|
||
if (event.state === "confirmed") {
|
||
if (event.pairing === "paired") return { pill: "pill-success", text: "已确认 · 双边" };
|
||
if (event.locked) return { pill: "pill-success", text: "已确认 · 单边锁定" };
|
||
return { pill: "pill-success", text: "已确认" };
|
||
}
|
||
return { pill: "pill-warn", text: "待确认" };
|
||
}
|
||
|
||
function renderTransfersEvents() {
|
||
const tbody = $("#transfersEventBody");
|
||
const empty = $("#transfersEventEmpty");
|
||
if (!tbody) return;
|
||
const events = state.transfersDetail?.events || [];
|
||
if (!events.length) {
|
||
tbody.innerHTML = "";
|
||
if (empty) empty.hidden = false;
|
||
return;
|
||
}
|
||
if (empty) empty.hidden = true;
|
||
tbody.innerHTML = events.map((ev) => {
|
||
const dir = ev.direction === "out" ? "转出" : ev.direction === "in" ? "转入" : "—";
|
||
const dirClass = ev.direction === "out" ? "amt-out" : ev.direction === "in" ? "amt-in" : "";
|
||
const wan = yuanToWan(ev.amount);
|
||
const amt = wan === null
|
||
? "—"
|
||
: `${ev.direction === "out" ? "−" : "+"}${Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
const st = transfersStateLabel(ev);
|
||
const date = String(ev.effective_at || "").slice(0, 10) || "—";
|
||
const pendingClass = ev.state === "pending" ? " is-pending" : "";
|
||
return `<tr class="${pendingClass.trim()}" data-event-id="${ev.event_id}">
|
||
<td class="num">${date}</td>
|
||
<td class="wrap">${ev.summary || "—"}</td>
|
||
<td>${dir}${ev.direction === "in" ? " ↓" : ev.direction === "out" ? " ↑" : ""}</td>
|
||
<td class="num-col ${dirClass}">${amt}</td>
|
||
<td><span class="pill ${st.pill}">${st.text}</span></td>
|
||
<td><button type="button" class="btn btn-sm" data-transfer-evidence="${ev.event_id}">原始流水</button></td>
|
||
</tr>`;
|
||
}).join("");
|
||
}
|
||
|
||
async function openTransferEvidence(eventId) {
|
||
const drawer = $("#transferEvidenceDrawer");
|
||
if (!drawer) return;
|
||
$("#tfEvTitle").textContent = "加载中…";
|
||
$("#tfEvDesc").textContent = "";
|
||
$("#tfEvFields").replaceChildren();
|
||
drawer.classList.add("is-open");
|
||
drawer.setAttribute("aria-hidden", "false");
|
||
const response = await fetch(`/api/company/transfer-events/${eventId}`).catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => null);
|
||
if (!response?.ok || !result || result.status !== "ok") {
|
||
$("#tfEvTitle").textContent = "无法打开原始流水";
|
||
$("#tfEvDesc").textContent = result?.message || "事件不存在或无权查看";
|
||
return;
|
||
}
|
||
const event = result.event || {};
|
||
const obs = Array.isArray(event.observations) ? event.observations[0] : null;
|
||
const cp = event.counterparty || {};
|
||
$("#tfEvTag").className = "pill pill-info";
|
||
$("#tfEvTag").textContent = "银行原始流水 · 只读";
|
||
$("#tfEvTitle").textContent = obs?.reference || `事件 #${event.event_id}`;
|
||
$("#tfEvDesc").textContent = obs?.import_batch_id
|
||
? `导入批次 IMP-${String(obs.import_batch_id).padStart(6, "0")}${obs.original_filename ? ` · ${obs.original_filename}` : ""}`
|
||
: "本方银行流水原文";
|
||
const income = obs?.income != null && Number(obs.income) !== 0;
|
||
const amountText = obs
|
||
? `${Number(income ? obs.income : obs.expense).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元(${income ? "收 / 转入" : "付 / 转出"})`
|
||
: `${event.amount || "—"} ${event.currency || "CNY"}`;
|
||
const fields = [
|
||
["本方账户", obs?.own_account_masked || "—"],
|
||
["交易时间", obs?.transaction_at || event.effective_at || "—"],
|
||
["方向", income ? "收入(转入)" : "支出(转出)"],
|
||
["金额", amountText],
|
||
["对方户名", obs?.counterparty_name || cp.company_name || "—"],
|
||
["对方账号", obs?.counterparty_account_masked || cp.account_number_masked || "—"],
|
||
["工作表", obs?.sheet_name || "—"],
|
||
["原始文件行号", obs?.source_row != null ? String(obs.source_row) : "—"],
|
||
["摘要", obs?.summary || event.reason || "—"],
|
||
["确认状态", event.pairing === "paired" ? "已确认 · 双边一致" : event.status || "—"],
|
||
];
|
||
const dl = $("#tfEvFields");
|
||
dl.replaceChildren(...fields.flatMap(([label, value]) => {
|
||
const dt = document.createElement("dt");
|
||
dt.textContent = label;
|
||
const dd = document.createElement("dd");
|
||
dd.className = "num";
|
||
dd.textContent = value;
|
||
return [dt, dd];
|
||
}));
|
||
$("[data-close-transfer-evidence]", drawer)?.focus();
|
||
}
|
||
|
||
function closeTransferEvidence({ restoreFocus = true } = {}) {
|
||
const drawer = $("#transferEvidenceDrawer");
|
||
if (!drawer) return;
|
||
drawer.classList.remove("is-open");
|
||
drawer.setAttribute("aria-hidden", "true");
|
||
}
|
||
|
||
function buildTransfersExportUrl() {
|
||
const detail = state.transfersDetail;
|
||
const summary = state.transfersSummary;
|
||
const params = new URLSearchParams();
|
||
if (detail?.company_id) {
|
||
params.set("counterparty_id", String(detail.company_id));
|
||
const f = detail.filters || {};
|
||
if (f.from) params.set("from", f.from);
|
||
if (f.to) params.set("to", f.to);
|
||
if (f.direction) params.set("direction", f.direction);
|
||
} else if (summary?.window) {
|
||
if (summary.window.start) params.set("from", summary.window.start);
|
||
if (summary.window.end) params.set("to", summary.window.end);
|
||
}
|
||
const qs = params.toString();
|
||
return `/api/company/intercompany/export.csv${qs ? `?${qs}` : ""}`;
|
||
}
|
||
|
||
function initTransfers() {
|
||
if (!$("[data-page='transfers']")) return;
|
||
|
||
$("#transfersRetryBtn")?.addEventListener("click", () => loadTransfersSummary());
|
||
$("#transfersExportBtn")?.addEventListener("click", () => {
|
||
window.location.href = buildTransfersExportUrl();
|
||
});
|
||
$("#transfersDetailExportBtn")?.addEventListener("click", () => {
|
||
window.location.href = buildTransfersExportUrl();
|
||
});
|
||
$("#transfersBackBtn")?.addEventListener("click", () => {
|
||
state.transfersDetail = null;
|
||
showTransfersOverviewLayer();
|
||
const title = $("#currentViewName");
|
||
if (title) title.textContent = "转账往来";
|
||
if (!state.transfersSummary) loadTransfersSummary();
|
||
else renderTransfersOverview(state.transfersSummary);
|
||
});
|
||
$("#transfersCpBody")?.addEventListener("click", (event) => {
|
||
const row = event.target.closest("tr[data-cp-id]");
|
||
if (!row) return;
|
||
openTransfersDetail(row.dataset.cpId, row.dataset.cpName);
|
||
});
|
||
$("#transfersFilterForm")?.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
if (!state.transfersDetail) return;
|
||
state.transfersDetail.filters = {
|
||
from: $("#tfFilterFrom")?.value || "",
|
||
to: $("#tfFilterTo")?.value || "",
|
||
direction: $("#tfFilterDirection")?.value || "",
|
||
state: $("#tfFilterState")?.value || "",
|
||
};
|
||
loadTransfersEvents({ reset: true });
|
||
});
|
||
const clearFilters = () => {
|
||
if (!state.transfersDetail) return;
|
||
const win = state.transfersSummary?.window || {};
|
||
state.transfersDetail.filters = {
|
||
from: win.start || "",
|
||
to: win.end || "",
|
||
direction: "",
|
||
state: "",
|
||
};
|
||
$("#tfFilterFrom").value = state.transfersDetail.filters.from;
|
||
$("#tfFilterTo").value = state.transfersDetail.filters.to;
|
||
$("#tfFilterDirection").value = "";
|
||
$("#tfFilterState").value = "";
|
||
loadTransfersEvents({ reset: true });
|
||
};
|
||
$("#tfFilterReset")?.addEventListener("click", clearFilters);
|
||
$("#tfEmptyClear")?.addEventListener("click", clearFilters);
|
||
$("#transfersLoadMore")?.addEventListener("click", () => loadTransfersEvents({ reset: false }));
|
||
$("#transfersEventBody")?.addEventListener("click", (event) => {
|
||
const btn = event.target.closest("[data-transfer-evidence]");
|
||
if (!btn) return;
|
||
openTransferEvidence(btn.dataset.transferEvidence);
|
||
});
|
||
$$("[data-close-transfer-evidence]").forEach((btn) => btn.addEventListener("click", () => closeTransferEvidence()));
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && $("#transferEvidenceDrawer")?.classList.contains("is-open")) {
|
||
event.stopPropagation();
|
||
closeTransferEvidence();
|
||
}
|
||
});
|
||
|
||
// 工作台概览与角标:与页面共用 summary
|
||
loadTransfersSummary();
|
||
}
|
||
|
||
function initCompany() {
|
||
$("#openAttestationFromWorkspace")?.addEventListener("click", () => openAttestationDialog());
|
||
$("#openAttestationFromFlows")?.addEventListener("click", () => openAttestationDialog());
|
||
$$("[data-close-attestation]").forEach((btn) => btn.addEventListener("click", () => closeModal("attestationDialog")));
|
||
$("#attestationForm")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const payload = {
|
||
bank_account_id: Number($("#att-account-id")?.value || 0),
|
||
gap_start: $("#att-gap-start")?.value || "",
|
||
gap_end: $("#att-gap-end")?.value || "",
|
||
reason: $("#att-reason")?.value || "",
|
||
evidence: $("#att-evidence")?.value || "",
|
||
};
|
||
const response = await fetch("/api/company/no-business-attestations", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
}).catch(() => null);
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response?.ok) {
|
||
showToast("提交失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
closeModal("attestationDialog");
|
||
await loadCompanyCoverageGaps();
|
||
showToast("已提交无业务说明", "等待管理员审核,通过后仅关闭断档提醒", "success");
|
||
});
|
||
|
||
renderCompanyManualRecords();
|
||
loadCompanyAccounts();
|
||
loadImportBatches();
|
||
|
||
$$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
|
||
$$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
|
||
|
||
$$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => showView("upload")));
|
||
|
||
// ── 流水导入:多步流程(选文件 → 解析 → 分 sheet 审核 → 确认) ──
|
||
$("#accountSelect")?.addEventListener("change", updateParseButton);
|
||
$("#fileInput")?.addEventListener("change", (event) => acceptFile(event.target.files[0]));
|
||
$("#removeFile")?.addEventListener("click", () => {
|
||
state.selectedFile = null;
|
||
$("#fileInput").value = "";
|
||
$("#filePreview").hidden = true;
|
||
$("#dropzone").hidden = false;
|
||
$("#parseResult").hidden = true;
|
||
updateParseButton();
|
||
});
|
||
const dropzone = $("#dropzone");
|
||
if (dropzone) {
|
||
["dragenter", "dragover"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add("dragover"); }));
|
||
["dragleave", "drop"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove("dragover"); }));
|
||
dropzone.addEventListener("click", () => $("#fileInput").click());
|
||
dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); $("#fileInput").click(); } });
|
||
dropzone.addEventListener("drop", (event) => acceptFile(event.dataTransfer.files[0]));
|
||
}
|
||
$("#uploadForm")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const stage = $("#parseButton").dataset.stage;
|
||
if (stage === "confirm" || stage === "done") {
|
||
const result = state.parseResult;
|
||
const parsed = result && ["parsed", "duplicate"].includes(result.status);
|
||
if (parsed && stage === "confirm") {
|
||
await confirmImport();
|
||
} else if (parsed) {
|
||
resetUpload();
|
||
showView("upload");
|
||
await loadImportBatches();
|
||
} else {
|
||
submitImportException();
|
||
}
|
||
return;
|
||
}
|
||
$("#parseButton").disabled = true;
|
||
$("#parseButton span").textContent = "正在识别表头...";
|
||
await parseFile();
|
||
});
|
||
|
||
// ── 导入批次详情弹窗 ──
|
||
$("#importRows")?.addEventListener("click", (event) => {
|
||
const btn = event.target.closest("[data-batch-view]");
|
||
if (!btn) return;
|
||
const tr = btn.closest("tr");
|
||
const batch = (state.batches || []).find((b) => String(b.id) === String(tr?.dataset.batchId));
|
||
if (!batch) return;
|
||
$("#mb-sub").textContent = `IMP-${String(batch.id).padStart(6, "0")} · ${batch.original_filename || ""}`;
|
||
$("#mb-id").textContent = `IMP-${String(batch.id).padStart(6, "0")}`;
|
||
$("#mb-bank").textContent = batch.bank_name || "—";
|
||
$("#mb-period").textContent = batch.period_start && batch.period_end ? `${batch.period_start} ~ ${batch.period_end}` : "—";
|
||
$("#mb-count").textContent = `${batch.confirmed_transactions ?? 0} 条`;
|
||
$("#mb-cover").textContent = batch.status === "exception" ? "未导入" : batch.pending_sheets > 0 ? `${batch.pending_sheets} 个待确认` : batch.confirmed_sheets > 0 ? "已确认" : "待处理";
|
||
$("#mb-parse").textContent = batch.exception_sheets > 0 ? `${batch.exception_sheets} 个异常` : batch.ignored_sheets > 0 ? `${batch.ignored_sheets} 个忽略` : "解析成功";
|
||
$("#mb-time").textContent = String(batch.created_at || "").slice(0, 16).replace("T", " ");
|
||
openModal("modal-batch");
|
||
});
|
||
|
||
// ── 手工记录:提交 + 撤回 ──
|
||
$("#manualEntryForm")?.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const data = new FormData(form);
|
||
const evidence = data.get("evidence");
|
||
if (evidence instanceof File && evidence.size > 20 * 1024 * 1024) {
|
||
showToast("证明附件超过限制", "请选择不超过 20 MB 的文件", "warn");
|
||
return;
|
||
}
|
||
const records = readStoredRecords(storageKeys.manual);
|
||
const record = {
|
||
id: `MR-${Date.now().toString().slice(-10)}`,
|
||
company: "A公司",
|
||
transactionDate: String(data.get("transactionDate")),
|
||
direction: String(data.get("direction")),
|
||
amount: Number(data.get("amount")),
|
||
sourceAccount: String(data.get("sourceAccount")),
|
||
counterpartyType: String(data.get("counterpartyType")),
|
||
counterparty: String(data.get("counterparty")).trim(),
|
||
counterpartyAccount: String(data.get("counterpartyAccount") || "").trim(),
|
||
subject: String(data.get("subject")),
|
||
summary: String(data.get("summary")).trim(),
|
||
remark: String(data.get("remark")).trim(),
|
||
bankReference: String(data.get("bankReference") || "").trim(),
|
||
evidenceName: evidence instanceof File ? evidence.name : "",
|
||
status: "待管理复核",
|
||
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||
};
|
||
records.push(record);
|
||
if (!writeStoredRecords(storageKeys.manual, records)) return;
|
||
renderCompanyManualRecords();
|
||
form.reset();
|
||
showToast("手工记录已提交", "管理员复核前不会纳入公司间往来计算", "success");
|
||
});
|
||
|
||
let manualWithdrawRow = null;
|
||
$("#manualRecordRows")?.addEventListener("click", (event) => {
|
||
const btn = event.target.closest("button[data-action='withdraw']");
|
||
if (!btn) return;
|
||
const tr = btn.closest("tr");
|
||
manualWithdrawRow = tr;
|
||
const cells = tr.cells;
|
||
$("#wd-date").textContent = cells[0]?.textContent.trim() || "—";
|
||
$("#wd-peer").textContent = cells[2]?.textContent.trim() || "—";
|
||
$("#wd-amount").textContent = cells[4]?.textContent.trim() || "—";
|
||
$("#wd-summary").textContent = cells[5]?.textContent.trim() || "—";
|
||
openModal("withdraw-modal");
|
||
});
|
||
$("#wd-confirm")?.addEventListener("click", () => {
|
||
if (manualWithdrawRow) {
|
||
const id = manualWithdrawRow.dataset.storedRecord;
|
||
if (id) {
|
||
const records = readStoredRecords(storageKeys.manual).filter((r) => r.id !== id);
|
||
writeStoredRecords(storageKeys.manual, records);
|
||
} else {
|
||
manualWithdrawRow.remove();
|
||
}
|
||
manualWithdrawRow = null;
|
||
}
|
||
closeModal("withdraw-modal");
|
||
renderCompanyManualRecords();
|
||
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
|
||
});
|
||
|
||
// ── 转账往来(方案 A)──
|
||
initTransfers();
|
||
|
||
// ── 往来确认 + 工作台权威待办 ──
|
||
initReconcile();
|
||
(async () => {
|
||
await loadCompanyWorkspace();
|
||
await loadCompanyCoverageGaps();
|
||
await renderReconcileMatchStack();
|
||
})();
|
||
|
||
// ── 通知 ──
|
||
initNotifications();
|
||
|
||
// ── 银行账户 ──
|
||
$("#openAccountDialog")?.addEventListener("click", () => openModal("accountDialog"));
|
||
$("#account-tbody")?.addEventListener("click", (event) => {
|
||
const btn = event.target.closest("[data-account-view]");
|
||
if (!btn) return;
|
||
const tr = btn.closest("tr");
|
||
const account = (state.accounts || []).find((a) => String(a.id) === String(tr?.dataset.accountId));
|
||
if (!account) return;
|
||
openAccountDetail(account);
|
||
});
|
||
$("#accountForm")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const data = new FormData(form);
|
||
const response = await fetch("/api/company/accounts", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
bank_name: String(data.get("bank") || "").trim(),
|
||
account_type: String(data.get("type") || ""),
|
||
account_number: String(data.get("accountNumber") || ""),
|
||
start_date: String(data.get("startDate") || ""),
|
||
}),
|
||
}).catch(() => null);
|
||
if (response?.status === 401 || response?.status === 403) {
|
||
window.location.href = "index.html";
|
||
return;
|
||
}
|
||
const result = await response?.json().catch(() => ({}));
|
||
if (!response || !response.ok) {
|
||
showToast("账户登记失败", result?.message || "请稍后重试", "danger");
|
||
return;
|
||
}
|
||
closeModal("accountDialog");
|
||
form.reset();
|
||
await loadCompanyAccounts();
|
||
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success");
|
||
});
|
||
}
|
||
|
||
if (portal === "entry") {
|
||
initEntry();
|
||
} else {
|
||
initAuthGuard().then((allowed) => {
|
||
if (!allowed) return;
|
||
initShell();
|
||
initFlowTools();
|
||
if (portal === "admin") {
|
||
initPairQueries();
|
||
initAdmin();
|
||
} else {
|
||
initCompany();
|
||
}
|
||
});
|
||
}
|