HEL-202: 基于 b27016d 返工起算日/期初/断档并补齐公司端
以 deploy/hel178 为基线整合 HEL-200 内核:迁移改为 0008 复用 system_settings;修复确认批次 list>int、截止日当天漏算与持久化断言; 公司端余额完整/降级口径与断档说明、管理端审核界面接线。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
b27016d71d
commit
27f2b0b69a
+46
-26
@@ -512,8 +512,9 @@
|
||||
<form id="systemSettings" novalidate>
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label for="cs-start">全局起算日</label>
|
||||
<input class="input num-input" type="date" name="startDate" id="cs-start" value="2026-01-01" />
|
||||
<span class="hint">期初余额以此日前一日的期末数为准</span>
|
||||
<input class="input num-input" type="date" name="startDate" id="cs-start" />
|
||||
<span class="hint" id="cs-start-hint">期初余额以此日前一日的期末数为准</span>
|
||||
<span class="pill pill-muted" id="cs-start-locked" style="display: none; margin-top: 8px;">已锁定</span>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label for="cs-day">每月结账日</label>
|
||||
@@ -547,15 +548,30 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">期初余额管理<span class="sub" id="openingSub">2026-01-01 起算的公司间往来期初数</span></span>
|
||||
<span class="card-title">期初余额管理<span class="sub" id="openingSub">公司间往来期初数</span></span>
|
||||
<button class="btn btn-sm" id="openOpeningDialog">新增</button>
|
||||
</div>
|
||||
<div class="notice info" id="openingSummary" style="margin: 0 0 14px; display: none;"></div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr><th>本方公司</th><th>对方公司</th><th>方向</th><th class="num-col">金额</th><th>录入人</th><th>录入时间</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="openingRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">变更记录<span class="sub">起算日与期初余额改动留痕</span></span>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr><th>本方公司</th><th>对方公司</th><th>科目</th><th>方向</th><th class="num-col">金额(万元)</th><th>生效日</th><th>状态</th></tr>
|
||||
<tr><th>时间</th><th>操作人</th><th>对象</th><th>变更</th><th>原因</th></tr>
|
||||
</thead>
|
||||
<tbody id="openingRows"></tbody>
|
||||
<tbody id="calculationChangeRows"><tr><td colspan="5" class="empty">暂无变更记录</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,31 +778,15 @@
|
||||
<select class="select" name="to" id="ob-to" required></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ob-subject">科目</label>
|
||||
<select class="select" name="subject" id="ob-subject" required>
|
||||
<option>应收</option>
|
||||
<option>应付</option>
|
||||
<option>其他应收</option>
|
||||
<option>其他应付</option>
|
||||
</select>
|
||||
<label for="ob-amount">金额</label>
|
||||
<input class="input num-input" name="amount" id="ob-amount" type="number" step="0.01" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ob-direction">方向</label>
|
||||
<select class="select" name="direction" id="ob-direction" required>
|
||||
<option>借方</option>
|
||||
<option>贷方</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ob-amount">金额(万元)</label>
|
||||
<input class="input num-input" name="amount" id="ob-amount" type="number" min="0" step="0.01" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ob-effective">生效日</label>
|
||||
<input class="input" name="effectiveDate" id="ob-effective" type="date" value="2026-01-01" required />
|
||||
<label for="ob-reason">录入原因</label>
|
||||
<input class="input" name="reason" id="ob-reason" required placeholder="必填,将写入变更记录" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" style="margin-top: 10px;">提交后进入复核,不能直接修改已确认期初。</p>
|
||||
<p class="hint" style="margin-top: 10px;">正数表示本方对对方为应收;提交后进入待确认,确认后才计入公司端余额。</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-close-opening>取消</button>
|
||||
<button type="submit" class="btn btn-primary">提交复核</button>
|
||||
@@ -795,6 +795,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="reasonDialog">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title" id="reasonDialogTitle">填写原因</span>
|
||||
<button type="button" class="modal-close" data-close-reason aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub" id="reasonDialogSub">该操作必须填写原因并留痕。</p>
|
||||
<form id="reasonForm" novalidate>
|
||||
<div class="field">
|
||||
<label for="reasonInput">原因</label>
|
||||
<textarea class="input" id="reasonInput" name="reason" rows="3" required placeholder="至少 2 个字"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-close-reason>取消</button>
|
||||
<button type="submit" class="btn btn-primary" id="reasonSubmit">确认</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核通过弹窗 -->
|
||||
<div class="modal-backdrop" id="modal-approve">
|
||||
<div class="modal">
|
||||
|
||||
+492
-15
@@ -18,6 +18,9 @@ const accountStatusLabels = {
|
||||
};
|
||||
|
||||
const state = {
|
||||
coverageGaps: [],
|
||||
companyCoverageGaps: [],
|
||||
companies: [],
|
||||
currentView: portal === "admin" ? "dashboard" : "workspace",
|
||||
selectedFile: null,
|
||||
parseResult: null,
|
||||
@@ -1145,6 +1148,327 @@ function fillCompanySelects(names) {
|
||||
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 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";
|
||||
@@ -1458,6 +1782,7 @@ async function loadAdminCompanies() {
|
||||
}
|
||||
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);
|
||||
@@ -1467,6 +1792,10 @@ function initAdmin() {
|
||||
renderStoredAdminReviews();
|
||||
updateAuditCounts();
|
||||
loadAdminCompanies();
|
||||
loadCalculationSettings();
|
||||
loadOpeningBalances();
|
||||
loadCalculationChanges();
|
||||
loadAdminCoverageGaps();
|
||||
initDashboard();
|
||||
|
||||
let activeAuditType = "all";
|
||||
@@ -1590,9 +1919,34 @@ function initAdmin() {
|
||||
);
|
||||
}
|
||||
|
||||
$("#auditRows")?.addEventListener("click", (event) => {
|
||||
$("#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);
|
||||
@@ -1783,12 +2137,42 @@ function initAdmin() {
|
||||
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),
|
||||
start_date: $("#cs-start")?.value || "",
|
||||
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" },
|
||||
@@ -1807,7 +2191,8 @@ function initAdmin() {
|
||||
}
|
||||
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
|
||||
applySystemSettings(result.settings || {});
|
||||
showToast("系统计算口径已保存", "修改前后值与操作人已留痕", "success");
|
||||
await loadCalculationSettings();
|
||||
showToast("系统计算口径已保存", "结账日与起算日变更已留痕", "success");
|
||||
});
|
||||
|
||||
$("#runClosingCheck")?.addEventListener("click", () => {
|
||||
@@ -1859,25 +2244,76 @@ function initAdmin() {
|
||||
|
||||
$("#openOpeningDialog")?.addEventListener("click", () => openModal("openingDialog"));
|
||||
$$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog")));
|
||||
$("#openingForm")?.addEventListener("submit", (event) => {
|
||||
$("#openingForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
if (data.get("from") === data.get("to")) {
|
||||
const fromName = data.get("from");
|
||||
const toName = data.get("to");
|
||||
if (fromName === toName) {
|
||||
showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初", "warn");
|
||||
return;
|
||||
}
|
||||
const row = document.createElement("tr");
|
||||
const self = document.createElement("td"); self.className = "cell-main"; self.textContent = data.get("from"); row.append(self);
|
||||
const peer = document.createElement("td"); peer.textContent = data.get("to"); row.append(peer);
|
||||
const subject = document.createElement("td"); subject.textContent = data.get("subject"); row.append(subject);
|
||||
const direction = document.createElement("td"); direction.textContent = data.get("direction"); row.append(direction);
|
||||
const amount = document.createElement("td"); amount.className = "num-col"; amount.textContent = Number(data.get("amount")).toLocaleString("zh-CN", { minimumFractionDigits: 2 }); row.append(amount);
|
||||
const date = document.createElement("td"); date.className = "num"; date.textContent = data.get("effectiveDate"); row.append(date);
|
||||
const status = document.createElement("td"); status.innerHTML = '<span class="pill pill-warn">待复核</span>'; row.append(status);
|
||||
$("#openingRows").append(row);
|
||||
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");
|
||||
event.currentTarget.reset();
|
||||
showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人", "success");
|
||||
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");
|
||||
});
|
||||
|
||||
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
|
||||
@@ -3151,6 +3587,20 @@ function renderTransfersOverview(summary) {
|
||||
const tfPendingAmount = $("#tfPendingAmount");
|
||||
if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount} 笔`;
|
||||
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWan(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 = formatWan(win.opening, { signed: true });
|
||||
if (endingEl) endingEl.innerHTML = formatWan(win.ending, { signed: true });
|
||||
} else {
|
||||
if (openingCard) openingCard.hidden = true;
|
||||
if (endingCard) endingCard.hidden = true;
|
||||
}
|
||||
|
||||
if (tfPendingCount) tfPendingCount.textContent = `${pCount} 笔`;
|
||||
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0);
|
||||
|
||||
@@ -3495,6 +3945,33 @@ function initTransfers() {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -98,6 +98,17 @@
|
||||
|
||||
<div class="grid grid-3-2" style="margin-top: 14px;">
|
||||
<div class="stack">
|
||||
<div class="notice danger" id="companyCoverageNotice" style="display: none; margin-bottom: 14px;">
|
||||
<div>
|
||||
<div class="n-title" id="companyCoverageTitle">流水断档提醒</div>
|
||||
<div class="n-body" id="companyCoverageBody"></div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px; gap: 8px; flex-wrap: wrap;">
|
||||
<button type="button" class="btn btn-sm btn-primary" data-view-link="upload">去上传</button>
|
||||
<button type="button" class="btn btn-sm" id="openAttestationFromWorkspace">提交无业务说明</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="workspaceTodos">
|
||||
<div class="card-head">
|
||||
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">按权威待确认单边流水同步</span></span>
|
||||
@@ -357,6 +368,16 @@
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="flows">
|
||||
<div class="notice danger" id="flowsCoverageNotice" style="display: none; margin-bottom: 14px;">
|
||||
<div>
|
||||
<div class="n-title">流水断档提醒</div>
|
||||
<div class="n-body" id="flowsCoverageBody"></div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px; gap: 8px;">
|
||||
<button type="button" class="btn btn-sm btn-primary" data-view-link="upload">去上传</button>
|
||||
<button type="button" class="btn btn-sm" id="openAttestationFromFlows">提交无业务说明</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>流水管理</h1>
|
||||
@@ -472,6 +493,11 @@
|
||||
|
||||
<div id="transfersData" hidden>
|
||||
<div class="grid grid-4" id="transfersStatGrid">
|
||||
<div class="card stat-card" id="tfStatOpeningCard" hidden>
|
||||
<div class="stat-label"><span class="stat-dot muted"></span>期初余额</div>
|
||||
<div class="stat-value" id="tfStatOpening">—</div>
|
||||
<div class="stat-foot">起算日前一日结转</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label"><span class="stat-dot info"></span>往来公司数</div>
|
||||
<div class="stat-value" id="tfStatCompanies">—<span class="unit">家</span></div>
|
||||
@@ -492,6 +518,11 @@
|
||||
<div class="stat-value" id="tfStatNet">—</div>
|
||||
<div class="stat-foot" id="tfStatNetFoot">正数=应收方向 · 负数=应付方向</div>
|
||||
</div>
|
||||
<div class="card stat-card" id="tfStatEndingCard" hidden>
|
||||
<div class="stat-label"><span class="stat-dot success"></span>期末余额</div>
|
||||
<div class="stat-value" id="tfStatEnding">—</div>
|
||||
<div class="stat-foot">期初 + 本期变动</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="xfer-split" id="transfersSplit" style="margin-top: 14px;">
|
||||
@@ -870,6 +901,37 @@
|
||||
</div>
|
||||
|
||||
<!-- 导入批次详情弹窗 -->
|
||||
<div class="modal-backdrop" id="attestationDialog">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">提交无业务说明</span>
|
||||
<button type="button" class="modal-close" data-close-attestation aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub">说明经管理员审核通过后仅关闭断档提醒,不会生成银行流水。</p>
|
||||
<form id="attestationForm" novalidate>
|
||||
<input type="hidden" id="att-gap-start" name="gap_start" />
|
||||
<input type="hidden" id="att-gap-end" name="gap_end" />
|
||||
<input type="hidden" id="att-account-id" name="bank_account_id" />
|
||||
<div class="field">
|
||||
<label>断档区间</label>
|
||||
<div class="meta num" id="att-gap-label">—</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="att-reason">说明(至少 5 个字)</label>
|
||||
<textarea class="input" id="att-reason" name="reason" rows="3" required></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="att-evidence">佐证(可选)</label>
|
||||
<input class="input" id="att-evidence" name="evidence" placeholder="如:节假日无业务、账户停用说明编号" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-close-attestation>取消</button>
|
||||
<button type="submit" class="btn btn-primary">提交审核</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="modal-batch">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
|
||||
Reference in New Issue
Block a user