HEL-200: 起算日、期初余额与流水断档检测
新增 0006 迁移与 calculation 模块,实现起算日设置、公司对期初 revision 链、 覆盖断档重算、无业务说明审核,以及完整/降级余额 API;管理端结账与期初页接入真实接口。 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
951b353765
commit
7826458892
+25
-25
@@ -608,8 +608,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>
|
||||
@@ -643,15 +644,30 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">期初余额管理<span class="sub">2026-01-01 起算的公司间往来期初数</span></span>
|
||||
<span class="card-title">期初余额管理<span class="sub" id="openingSubtitle">公司间往来期初数</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>
|
||||
@@ -870,31 +886,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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
+195
-18
@@ -994,6 +994,122 @@ function fillCompanySelects(names) {
|
||||
setSelectOptions($('#openingDialog [name="to"]'), names);
|
||||
}
|
||||
|
||||
function openingStatusPill(status) {
|
||||
if (status === "confirmed") return { cls: "pill-success", label: "已确认" };
|
||||
if (status === "void") return { cls: "pill-danger", label: "已作废" };
|
||||
return { cls: "pill-warn", label: "待确认" };
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) return "—";
|
||||
return num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function loadCalculationSettings() {
|
||||
const response = await fetch("/api/admin/settings/calculation-start").catch(() => null);
|
||||
if (!response?.ok) return;
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data) return;
|
||||
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);
|
||||
}
|
||||
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 = $("#openingSubtitle");
|
||||
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.innerHTML = `<div><div class="n-title">共 ${data.summary.company_count} 家公司 · 已确认 ${data.summary.confirmed_pair_count} 对公司对期初</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOpeningBalances() {
|
||||
const tbody = $("#openingRows");
|
||||
if (!tbody) return;
|
||||
const response = await fetch("/api/admin/opening-balances").catch(() => null);
|
||||
if (!response?.ok) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty">加载失败</td></tr>';
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
const items = data?.items || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty">暂无期初记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map((item) => {
|
||||
const pill = openingStatusPill(item.status);
|
||||
const direction = Number(item.amount) >= 0 ? "应收" : "应付";
|
||||
const dirCls = Number(item.amount) >= 0 ? "var(--success)" : "var(--danger)";
|
||||
const actions = item.status === "draft"
|
||||
? `<button type="button" class="btn btn-sm" data-confirm-opening="${item.id}">确认</button>`
|
||||
: item.status === "confirmed"
|
||||
? `<button type="button" class="btn btn-sm btn-ghost btn-danger" data-void-opening="${item.id}">作废</button>`
|
||||
: "";
|
||||
const voidStyle = item.status === "void" ? ' style="text-decoration: line-through;"' : "";
|
||||
return `<tr${voidStyle} data-opening-id="${item.id}">
|
||||
<td class="cell-main">${item.company_low_name}</td>
|
||||
<td>${item.company_high_name}</td>
|
||||
<td style="color:${dirCls}">${direction}</td>
|
||||
<td class="num-col">${formatMoney(Math.abs(item.amount))}</td>
|
||||
<td>${item.actor_username || "—"}</td>
|
||||
<td class="num">${(item.created_at || "").slice(0, 10)}</td>
|
||||
<td><span class="pill ${pill.cls}">${pill.label}</span></td>
|
||||
<td>${actions}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
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) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty">暂无变更记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map((item) => {
|
||||
const before = item.before ? JSON.stringify(item.before) : "—";
|
||||
const after = item.after ? JSON.stringify(item.after) : "—";
|
||||
return `<tr>
|
||||
<td class="num">${(item.created_at || "").replace("T", " ").slice(0, 16)}</td>
|
||||
<td>${item.actor_username || "—"}</td>
|
||||
<td>${item.target}</td>
|
||||
<td class="wrap">${before} → ${after}</td>
|
||||
<td class="wrap">${item.reason || "—"}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
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 || [];
|
||||
const badge = $('.side-nav a[data-view="audit"] .nav-badge');
|
||||
if (badge) badge.textContent = String(state.coverageGaps.length || "");
|
||||
}
|
||||
|
||||
async function loadAdminCompanies() {
|
||||
const tbody = $("#companyTable tbody");
|
||||
showTableLoading(tbody, 6);
|
||||
@@ -1005,6 +1121,7 @@ async function loadAdminCompanies() {
|
||||
const result = await response.json().catch(() => null);
|
||||
const companies = result?.companies || [];
|
||||
renderAdminCompanyTable(companies);
|
||||
state.companies = companies;
|
||||
fillCompanySelects(companies.map((company) => company.name));
|
||||
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
|
||||
}
|
||||
@@ -1013,6 +1130,10 @@ function initAdmin() {
|
||||
renderStoredAdminReviews();
|
||||
updateAuditCounts();
|
||||
loadAdminCompanies();
|
||||
loadCalculationSettings();
|
||||
loadOpeningBalances();
|
||||
loadCalculationChanges();
|
||||
loadAdminCoverageGaps();
|
||||
|
||||
let activeAuditType = "all";
|
||||
function filterAuditRows() {
|
||||
@@ -1227,16 +1348,30 @@ function initAdmin() {
|
||||
remind?.addEventListener("change", renderSwitch);
|
||||
renderSwitch();
|
||||
|
||||
$("#systemSettings")?.addEventListener("submit", (event) => {
|
||||
$("#systemSettings")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const day = parseInt($("#cs-day")?.value, 10);
|
||||
const tip = $("#cs-save-tip");
|
||||
if (day && (day < 1 || day > 28)) {
|
||||
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; }
|
||||
const startDate = $("#cs-start")?.value;
|
||||
const reason = window.prompt("请填写修改起算日的原因(必填):");
|
||||
if (!reason || reason.trim().length < 2) {
|
||||
showToast("未保存", "修改起算日必须填写原因", "warn");
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/admin/settings/calculation-start", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ calculation_start_date: startDate, reason: reason.trim() }),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) {
|
||||
showToast("保存失败", result?.message || "请稍后重试", "danger");
|
||||
return;
|
||||
}
|
||||
const tip = $("#cs-save-tip");
|
||||
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
|
||||
showToast("系统计算口径已保存", "正式系统将记录修改前后值与操作人", "success");
|
||||
await loadCalculationSettings();
|
||||
await loadCalculationChanges();
|
||||
showToast("起算日已保存", "变更已写入留痕记录", "success");
|
||||
});
|
||||
|
||||
$("#runClosingCheck")?.addEventListener("click", () => {
|
||||
@@ -1288,25 +1423,67 @@ 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;
|
||||
}
|
||||
let amount = Number(data.get("amount"));
|
||||
if (Number(data.get("direction") || "应收") === "应付") amount = -amount;
|
||||
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();
|
||||
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 = window.prompt("请填写原因(必填):");
|
||||
if (!reason || reason.trim().length < 2) 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: reason.trim() }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) {
|
||||
showToast("操作失败", result?.message || "请稍后重试", "danger");
|
||||
return;
|
||||
}
|
||||
await loadOpeningBalances();
|
||||
await loadCalculationChanges();
|
||||
showToast(confirmBtn ? "期初已确认" : "期初已作废", "变更已留痕", "success");
|
||||
});
|
||||
|
||||
$("#reminderForm")?.addEventListener("submit", (event) => {
|
||||
|
||||
Reference in New Issue
Block a user