Compare commits

...
Author SHA1 Message Date
0fa32e1571 HEL-204: 修复弹窗作用域、断档首屏与期初表单 reset
将 openModal/closeModal 提升为顶层定义;公司端启动调用 loadCompanyCoverageGaps;异步提交前保存 form 引用。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:25:21 +00:00
27f2b0b69a 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>
2026-08-27 16:38:36 +00:00
b27016d71d HEL-177: 公司端按方案 A 实现转账往来四层页面
工作台概览小卡 + 侧栏入口;总览/对方汇总/明细/原始流水抽屉接入 HEL-175/176 接口;无期初时展示期间净变动;模块内用 info 品蓝。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 13:45:19 +00:00
总工 96309eb2cc HEL-177: merge HEL-174 confirm-status green into transfers page base 2026-08-27 13:17:28 +00:00
7e7354a809 HEL-174: 公司端往来确认完成态改为 success 绿
确认全部完成后,账期流程第 3 步从 doing(warn 黄)切到 done(success 绿),
本月待办 pill 与阻断提示同步用 success;待确认仍保留 warn。补前端契约与 DOM 色值测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 12:56:20 +00:00
15 changed files with 4494 additions and 83 deletions
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""HEL-174: 360 / 820 / 1440 截图——待确认黄 vs 已完成绿。"""
from __future__ import annotations
import re
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = Path(__file__).resolve().parent.parent / "hel174-shots"
OUT.mkdir(parents=True, exist_ok=True)
VIEWPORTS = [
(1440, 900, "1440"),
(820, 900, "820"),
(360, 800, "360"),
]
def extract_fn(source: str, name: str) -> str:
marker = f"function {name}("
start = source.index(marker)
depth = 0
for i, ch in enumerate(source[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise RuntimeError(name)
def main() -> None:
app_js = (WEB / "app.js").read_text(encoding="utf-8")
inject = "\n".join(
[
"const state = {};",
"function $(sel, root) { return (root || document).querySelector(sel); }",
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
extract_fn(app_js, "formatWorkspaceAmount"),
extract_fn(app_js, "applyCompanyWorkspace"),
]
)
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{port}"
html = f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="{base}/design-system.css"/>
<title>HEL-174 往来确认状态色</title>
<style>
body {{ margin: 0; background: var(--bg); font-family: var(--font-body); color: var(--fg); }}
.shot-wrap {{ padding: 16px; max-width: 1100px; margin: 0 auto; }}
.shot-label {{ font-size: 13px; color: var(--muted); margin: 0 0 10px; }}
</style>
</head>
<body data-portal="company">
<div class="shot-wrap">
<p class="shot-label" id="shotLabel">状态预览</p>
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
<div class="card" id="workspaceTodos" style="margin-top:12px;">
<div class="card-head">
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">…</span></span>
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
</div>
<div id="workspaceTodoList"></div>
<div class="table-foot" id="workspaceTodoFoot"><span>…</span></div>
</div>
<div class="card" style="margin-top:12px;">
<div class="card-head">
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">…</span></span>
</div>
<div class="flow">
<a class="flow-step part" href="#upload">
<div class="fs-top"><span class="fs-idx">01</span><span class="fs-dot"></span><span class="fs-name">流水导入</span></div>
<div class="fs-state">部分完成</div>
</a>
<a class="flow-step done" href="#manual">
<div class="fs-top"><span class="fs-idx">02</span><span class="fs-dot"></span><span class="fs-name">手工补录</span></div>
<div class="fs-state">已完成</div>
</a>
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
<div class="fs-meta" id="workspaceConfirmMeta">…</div>
</a>
<div class="flow-step wait">
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
<div class="fs-state">等待集团</div>
</div>
</div>
</div>
<div class="notice warn" id="blocking-notice" style="margin-top:12px;">
<div>
<div class="n-title" id="notice-title">…</div>
<div class="n-body" id="notice-body">…</div>
</div>
</div>
<div style="margin-top:12px;">
<span class="pill pill-warn">待确认</span>
<span class="pill pill-success" style="margin-left:8px;">已确认</span>
</div>
<span class="tab-count" id="count-match" hidden>0</span>
<nav class="side-nav" hidden><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
</div>
</body></html>"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page()
for width, height, tag in VIEWPORTS:
page.set_viewport_size({"width": width, "height": height})
page.set_content(html, wait_until="domcontentloaded")
page.add_script_tag(content=inject)
page.evaluate(
"""() => {
document.getElementById('shotLabel').textContent = '待确认(应黄)';
applyCompanyWorkspace({
pending_unilateral: 3, pending_total: 3,
unilateral_events: [
{event_id:1, amount:'100000.00', currency:'CNY',
counterparty_company_name:'金牛贸易', effective_at:'2026-07-03'},
{event_id:2, amount:'200000.00', currency:'CNY',
counterparty_company_name:'金牛物流', effective_at:'2026-07-11'},
{event_id:3, amount:'300000.00', currency:'CNY',
counterparty_company_name:'金牛置业', effective_at:'2026-07-24'}
]
});
}"""
)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"hel174-pending-{tag}.png"), full_page=True)
page.evaluate(
"""() => {
document.getElementById('shotLabel').textContent = '已完成(应绿)';
applyCompanyWorkspace({
pending_unilateral: 0, pending_total: 0, unilateral_events: []
});
}"""
)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"hel174-done-{tag}.png"), full_page=True)
colors = page.evaluate(
"""() => {
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
const state = document.getElementById('workspaceConfirmState');
const pill = document.getElementById('workspacePendingStatus');
return {
stepClass: step.className,
stateText: state.textContent,
stateColor: getComputedStyle(state).color,
pillClass: pill.className,
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
}"""
)
assert "done" in colors["stepClass"], colors
assert "pill-success" in colors["pillClass"], colors
assert colors["stateText"] == "已完成", colors
assert not colors["overflowX"], colors
print(tag, colors)
browser.close()
httpd.shutdown()
print("shots:", sorted(p.name for p in OUT.glob("*.png")))
if __name__ == "__main__":
main()
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""HEL-177: 转账往来页 360/820/1440 冒烟截图(mock 接口,不依赖登录库)。"""
from __future__ import annotations
import json
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = ROOT / "hel177-shots"
OUT.mkdir(parents=True, exist_ok=True)
VIEWPORTS = [(1440, 900, "1440"), (820, 900, "820"), (360, 800, "360")]
SUMMARY = {
"status": "ok",
"own_company": {"id": 3, "name": "河南金牛煤业有限公司"},
"window": {
"start": "2026-01-01",
"end": "2026-08-20",
"has_opening": False,
"opening": None,
"ending": None,
},
"confirmed": {
"outflow_total": "123860000.00",
"outflow_count": 48,
"inflow_total": "184205000.00",
"inflow_count": 77,
"net_change": "-60345000.00",
"net_direction": "payable",
},
"pending": {"count": 3, "amount_total": "3200000.00"},
"counterparties": [
{
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"confirmed_outflow": "52000000.00",
"confirmed_inflow": "86000000.00",
"net": "-34000000.00",
"pending_count": 2,
"last_effective_at": "2026-08-18",
},
{
"company_id": 6,
"company_name": "河南金牛贸易有限公司",
"confirmed_outflow": "10000000.00",
"confirmed_inflow": "12605000.00",
"net": "-2605000.00",
"pending_count": 1,
"last_effective_at": "2026-08-10",
},
{
"company_id": 7,
"company_name": "河南金牛农业科技发展有限公司",
"confirmed_outflow": "61860000.00",
"confirmed_inflow": "85600000.00",
"net": "-23740000.00",
"pending_count": 0,
"last_effective_at": "2026-07-30",
},
],
}
# 修正 mock:按 HEL-169 净变动=转出-转入,正数应收
SUMMARY["confirmed"] = {
"outflow_total": "184205000.00",
"outflow_count": 80,
"inflow_total": "123860000.00",
"inflow_count": 48,
"net_change": "60345000.00",
"net_direction": "receivable",
}
SUMMARY["counterparties"] = [
{
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"confirmed_outflow": "86000000.00",
"confirmed_inflow": "52000000.00",
"net": "34000000.00",
"pending_count": 2,
"last_effective_at": "2026-08-18",
},
{
"company_id": 6,
"company_name": "河南金牛贸易有限公司",
"confirmed_outflow": "12605000.00",
"confirmed_inflow": "10000000.00",
"net": "2605000.00",
"pending_count": 1,
"last_effective_at": "2026-08-10",
},
{
"company_id": 7,
"company_name": "河南金牛农业科技发展有限公司",
"confirmed_outflow": "85600000.00",
"confirmed_inflow": "61860000.00",
"net": "23740000.00",
"pending_count": 0,
"last_effective_at": "2026-07-30",
},
]
EVENTS = {
"status": "ok",
"window": {"start": "2026-01-01", "end": "2026-08-20"},
"events": [
{
"event_id": 101,
"direction": "in",
"state": "confirmed",
"pairing": "paired",
"locked": False,
"amount": "12000000.00",
"currency": "CNY",
"effective_at": "2026-08-18",
"summary": "周转资金调拨",
"status": "matched",
},
{
"event_id": 102,
"direction": "out",
"state": "pending",
"pairing": "unilateral",
"locked": False,
"amount": "1500000.00",
"currency": "CNY",
"effective_at": "2026-08-12",
"summary": "工程款结算(待确认)",
"status": "unresolved",
},
],
"next_cursor": None,
"has_more": False,
}
DETAIL = {
"status": "ok",
"event": {
"event_id": 101,
"classification": "intercompany",
"pairing": "paired",
"status": "matched",
"amount": "12000000.00",
"currency": "CNY",
"effective_at": "2026-08-18",
"mode": "auto",
"reason": "双边匹配",
"counterparty": {
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"account_number_masked": "****8821",
},
"observations": [
{
"source_row_id": 1,
"role": "payee",
"source_row": 148,
"sheet_name": "流水",
"transaction_at": "2026-08-18 10:22:00",
"income": "12000000.00",
"expense": None,
"import_batch_id": 19,
"original_filename": "工行3305_202608.xls",
"own_account_masked": "****3305",
"counterparty_account_masked": "****8821",
"counterparty_name": "河南金牛置业有限公司",
"summary": "周转资金调拨",
"reference": "JS20260818-00317",
}
],
},
}
def main() -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{port}"
mock_js = f"""
window.__HEL177_MOCK__ = true;
const SUMMARY = {json.dumps(SUMMARY, ensure_ascii=False)};
const EVENTS = {json.dumps(EVENTS, ensure_ascii=False)};
const DETAIL = {json.dumps(DETAIL, ensure_ascii=False)};
const _fetch = window.fetch.bind(window);
window.fetch = async (input, init) => {{
const url = String(input);
if (url.includes('/api/me') || url.includes('/api/session')) {{
return new Response(JSON.stringify({{role:'company', company_id:3, username:'牛女士', company_name:'河南金牛煤业有限公司'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/workspace')) {{
return new Response(JSON.stringify({{status:'ok', pending_unilateral:0, pending_total:0, unilateral_events:[]}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/intercompany/summary')) {{
return new Response(JSON.stringify(SUMMARY), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/intercompany/events')) {{
return new Response(JSON.stringify(EVENTS), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/transfer-events/')) {{
return new Response(JSON.stringify(DETAIL), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/')) {{
return new Response(JSON.stringify({{status:'ok'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
return _fetch(input, init);
}};
"""
console_errors: list[str] = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.on("pageerror", lambda exc: console_errors.append(f"pageerror:{exc}"))
page.on("console", lambda msg: console_errors.append(f"console:{msg.type}:{msg.text}") if msg.type == "error" else None)
for width, height, label in VIEWPORTS:
page.set_viewport_size({"width": width, "height": height})
page.add_init_script(mock_js)
# bypass auth guard
page.add_init_script(
"""
const orig = window.fetch;
// initAuthGuard 读 /api/me;已在 mock 中处理
"""
)
page.goto(f"{base}/company.html", wait_until="domcontentloaded")
page.evaluate(
"""() => {
// 若鉴权把页面踢走,强制停留
if (!document.body || document.body.dataset.portal !== 'company') return;
}"""
)
# 等 summary 渲染
page.wait_for_timeout(600)
# 切到转账往来
page.evaluate("() => { if (typeof showView === 'function') showView('transfers'); }")
page.wait_for_selector("#transfersData:not([hidden])", timeout=5000)
page.wait_for_timeout(300)
overflow = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
assert not overflow, f"{label} overview overflow"
# 文案:期间净变动,无期末余额误用
net_title = page.inner_text("#tfStatNetTitle")
assert "期间净变动" in net_title, net_title
page.screenshot(path=str(OUT / f"overview-{label}.png"), full_page=True)
# 下钻明细
page.click("#transfersCpBody tr.clickable")
page.wait_for_selector("#transfersDetailLayer:not([hidden])", timeout=5000)
page.wait_for_timeout(300)
overflow2 = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
assert not overflow2, f"{label} detail overflow"
page.screenshot(path=str(OUT / f"detail-{label}.png"), full_page=True)
# 打开抽屉
page.click("[data-transfer-evidence]")
page.wait_for_selector("#transferEvidenceDrawer.is-open", timeout=5000)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"drawer-{label}.png"), full_page=True)
page.click("[data-close-transfer-evidence]")
browser.close()
hard = [e for e in console_errors if "Failed to load resource" not in e and "favicon" not in e]
print("shots:", list(OUT.glob("*.png")))
print("console_errors:", hard)
if hard:
raise SystemExit(1)
print("OK")
if __name__ == "__main__":
main()
+436 -3
View File
@@ -11,8 +11,9 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import (
auth, company_transfers, dashboard, importing, ledger_events, manual_records,
master_data, matching, multipart, personal_transit, positions, settings, subjects,
auth, calculation, company_transfers, dashboard, importing, ledger_events,
manual_records, master_data, matching, multipart, personal_transit, positions,
settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now
@@ -77,6 +78,30 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/company/accounts":
self._handle_company_accounts()
return
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_get()
return
if path == "/api/admin/opening-balances":
self._handle_admin_opening_balances_get()
return
if path == "/api/admin/coverage-gaps":
self._handle_admin_coverage_gaps(query)
return
if path == "/api/admin/calculation-changes":
self._handle_admin_calculation_changes()
return
if path == "/api/admin/balances/pair":
self._handle_admin_pair_balance(query)
return
if path == "/api/company/coverage-gaps":
self._handle_company_coverage_gaps()
return
if path == "/api/company/balances":
self._handle_company_balances(query)
return
if path == "/api/company/balances/pair":
self._handle_company_pair_balance(query)
return
if path == "/api/admin/audit-log":
self._handle_admin_audit_log(query)
return
@@ -267,6 +292,36 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/admin/settings":
self._handle_admin_update_settings()
return
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_put()
return
if path == "/api/admin/opening-balances":
self._handle_admin_opening_balances_post()
return
opening_confirm = re.fullmatch(r"/api/admin/opening-balances/(\d+)/confirm", path)
if opening_confirm:
self._handle_admin_opening_balance_confirm(int(opening_confirm.group(1)))
return
opening_revise = re.fullmatch(r"/api/admin/opening-balances/(\d+)/revisions", path)
if opening_revise:
self._handle_admin_opening_balance_revise(int(opening_revise.group(1)))
return
opening_void = re.fullmatch(r"/api/admin/opening-balances/(\d+)/void", path)
if opening_void:
self._handle_admin_opening_balance_void(int(opening_void.group(1)))
return
if path == "/api/admin/coverage-gaps/recalculate":
self._handle_admin_recalculate_gaps()
return
attestation_review = re.fullmatch(
r"/api/admin/no-business-attestations/(\d+)/review", path
)
if attestation_review:
self._handle_admin_review_attestation(int(attestation_review.group(1)))
return
if path == "/api/company/no-business-attestations":
self._handle_company_submit_attestation()
return
if path == "/api/admin/reminders/send":
self._handle_admin_send_reminders()
return
@@ -301,6 +356,13 @@ class AppHandler(SimpleHTTPRequestHandler):
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
def do_PUT(self) -> None:
path = urlparse(self.path).path
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_put()
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
# ------------------------------------------------------------------
# Session helpers
# ------------------------------------------------------------------
@@ -796,6 +858,8 @@ class AppHandler(SimpleHTTPRequestHandler):
}
if outcome.get("matching") is not None:
payload["matching"] = outcome["matching"]
if decision == "confirm" and len(outcome.get("updated") or []) > 0:
calculation.recalculate_coverage_gaps(connection)
self._send_json(200, payload)
finally:
connection.close()
@@ -1452,6 +1516,366 @@ class AppHandler(SimpleHTTPRequestHandler):
# System settings (admin read/write, persisted + audited)
# ------------------------------------------------------------------
def _handle_admin_calculation_start_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
payload = calculation.start_date_payload(connection)
payload["status"] = "ok"
payload["summary"] = calculation.opening_coverage_summary(connection)
self._send_json(200, payload)
finally:
connection.close()
def _handle_admin_calculation_start_put(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
result = calculation.set_calculation_start_date(
connection,
str(data.get("calculation_start_date") or ""),
str(data.get("reason") or ""),
admin,
)
except calculation.LockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **result})
finally:
connection.close()
def _handle_admin_opening_balances_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
items = calculation.list_opening_balances(connection)
self._send_json(
200,
{
"status": "ok",
"items": items,
"summary": calculation.opening_coverage_summary(connection),
},
)
finally:
connection.close()
def _handle_admin_opening_balances_post(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
company_a = int(data.get("company_id_low") or data.get("from_company_id"))
company_b = int(data.get("company_id_high") or data.get("to_company_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定有效的公司。"})
return
try:
item = calculation.create_opening_balance(
connection,
company_a,
company_b,
data.get("amount"),
str(data.get("reason") or ""),
admin,
viewer_company_id=company_a,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_confirm(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body() or {}
try:
item = calculation.confirm_opening_balance(
connection, revision_id, str(data.get("reason") or ""), admin
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_revise(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
item = calculation.revise_opening_balance(
connection,
revision_id,
data.get("amount"),
str(data.get("reason") or ""),
admin,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_void(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body() or {}
try:
item = calculation.void_opening_balance(
connection, revision_id, str(data.get("reason") or ""), admin
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_coverage_gaps(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
status = (query.get("status") or [None])[0]
items = calculation.list_coverage_gaps(connection, status=status)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_admin_recalculate_gaps(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
rebuilt = calculation.recalculate_coverage_gaps(connection)
self._send_json(200, {"status": "ok", "rebuilt": rebuilt})
finally:
connection.close()
def _handle_admin_calculation_changes(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
items = calculation.list_change_log(connection)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _resolve_company_id(
self, connection, query: dict[str, list[str]], field: str
) -> int | None:
raw = (query.get(field) or [None])[0]
if raw is None:
return None
try:
return int(raw)
except ValueError:
self._send_json(400, {"status": "error", "message": f"{field} 参数无效。"})
return -1
def _handle_admin_pair_balance(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
from_id = self._resolve_company_id(connection, query, "from_company_id")
to_id = self._resolve_company_id(connection, query, "to_company_id")
if from_id == -1 or to_id == -1:
return
if from_id is None or to_id is None:
self._send_json(400, {"status": "error", "message": "必须指定 from_company_id 与 to_company_id。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
balance = calculation.compute_pair_balance(
connection, from_id, to_id, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "balance": balance})
finally:
connection.close()
def _handle_company_coverage_gaps(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
items = calculation.list_coverage_gaps(
connection, company_id=user["company_id"]
)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_company_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
payload = calculation.compute_company_balances(
connection, user["company_id"], cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
payload["status"] = "ok"
self._send_json(200, payload)
finally:
connection.close()
def _handle_company_pair_balance(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
counterparty_id = self._resolve_company_id(connection, query, "counterparty_id")
if counterparty_id == -1:
return
if counterparty_id is None:
self._send_json(400, {"status": "error", "message": "必须指定 counterparty_id。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
balance = calculation.compute_pair_balance(
connection, user["company_id"], counterparty_id, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "balance": balance})
finally:
connection.close()
def _handle_company_submit_attestation(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
data = self._read_json_body()
if data is None:
return
try:
bank_account_id = int(data.get("bank_account_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定 bank_account_id。"})
return
try:
item = calculation.submit_no_business_attestation(
connection,
company_id=user["company_id"],
bank_account_id=bank_account_id,
gap_start=str(data.get("gap_start") or ""),
gap_end=str(data.get("gap_end") or ""),
reason=str(data.get("reason") or ""),
evidence=str(data.get("evidence") or "") or None,
actor=user,
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_review_attestation(self, attestation_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
decision = str(data.get("decision") or "")
try:
item = calculation.review_no_business_attestation(
connection,
attestation_id,
decision,
str(data.get("review_reason") or ""),
admin,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_settings(self) -> None:
connection = connect(DB_PATH)
try:
@@ -2439,10 +2863,12 @@ class AppHandler(SimpleHTTPRequestHandler):
SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
r.own_account, r.own_name, r.counterparty_account,
r.counterparty_name, r.summary, r.reference,
s.sheet_name, b.company_id AS batch_company_id
s.sheet_name, b.id AS import_batch_id,
f.original_filename, b.company_id AS batch_company_id
FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN import_batches b ON b.id = s.import_batch_id
JOIN source_files f ON f.id = b.source_file_id
WHERE r.id = ?
""",
(observation["source_row_id"],),
@@ -2459,9 +2885,16 @@ class AppHandler(SimpleHTTPRequestHandler):
"income": row["income"],
"expense": row["expense"],
"batch_company_id": row["batch_company_id"],
"import_batch_id": row["import_batch_id"],
"original_filename": row["original_filename"],
"own_account_masked": master_data.mask_account_number(row["own_account"])
if row["own_account"]
else None,
"counterparty_account_masked": master_data.mask_account_number(
row["counterparty_account"]
)
if row["counterparty_account"]
else None,
"counterparty_name": row["counterparty_name"],
"summary": row["summary"],
"reference": row["reference"],
+958
View File
@@ -0,0 +1,958 @@
"""Calculation window: start date, opening balances, coverage gaps and balances."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from . import master_data, matching
SETTING_START_DATE = "calculation_start_date"
OPENING_STATUSES = ("draft", "confirmed", "superseded", "void")
GAP_KINDS = ("head", "mid", "tail")
GAP_STATUSES = ("open", "closed_attested")
ATTESTATION_STATUSES = ("pending", "approved", "rejected")
class LockedError(ValueError):
"""Start date cannot change after a period is closed."""
class ConflictError(ValueError):
"""Revision or state conflict."""
def utc_today() -> str:
return datetime.now(timezone.utc).date().isoformat()
def _parse_decimal(value: object) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError):
raise ValueError("金额格式无效。") from None
def normalize_pair(company_a: int, company_b: int) -> tuple[int, int]:
if company_a == company_b:
raise ValueError("两家公司不能相同。")
return (company_a, company_b) if company_a < company_b else (company_b, company_a)
def signed_from_viewer(viewer_id: int, low_id: int, high_id: int, amount: Decimal) -> Decimal:
return amount if viewer_id == low_id else -amount
def get_setting(connection: sqlite3.Connection, key: str) -> str | None:
row = connection.execute(
"SELECT value FROM system_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row is not None else None
def get_calculation_start_date(connection: sqlite3.Connection) -> str | None:
return get_setting(connection, SETTING_START_DATE)
def has_closed_periods(connection: sqlite3.Connection) -> bool:
row = connection.execute("SELECT 1 FROM closed_periods LIMIT 1").fetchone()
return row is not None
def is_start_date_locked(connection: sqlite3.Connection) -> bool:
return has_closed_periods(connection)
def set_calculation_start_date(
connection: sqlite3.Connection,
start_date: str,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
start_date = master_data.validate_date(start_date, "起算日", required=True)
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("修改起算日必须填写原因。")
if is_start_date_locked(connection):
raise LockedError("已有结账月份,起算日已锁定。")
before = get_calculation_start_date(connection)
now = utc_now()
with connection:
connection.execute(
"""
INSERT INTO system_settings (key, value, updated_at, updated_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
(SETTING_START_DATE, start_date, now, actor["id"]),
)
connection.execute(
"""
INSERT INTO system_setting_changes (
key, before_value, after_value,
actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
SETTING_START_DATE,
before,
start_date,
actor["id"],
actor["username"],
now,
),
)
master_data.record_change(
connection,
"system_setting",
0,
"update_start_date",
{"calculation_start_date": before},
{"calculation_start_date": start_date},
reason,
actor,
)
recalculate_coverage_gaps(connection)
return {
"calculation_start_date": start_date,
"locked": False,
"previous": before,
}
def start_date_payload(connection: sqlite3.Connection) -> dict[str, object]:
return {
"calculation_start_date": get_calculation_start_date(connection),
"locked": is_start_date_locked(connection),
}
def _current_opening_revision(
connection: sqlite3.Connection, low_id: int, high_id: int
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ?
AND status IN ('draft', 'confirmed')
ORDER BY revision DESC
LIMIT 1
""",
(low_id, high_id),
).fetchone()
def _next_revision(connection: sqlite3.Connection, low_id: int, high_id: int) -> int:
row = connection.execute(
"""
SELECT MAX(revision) AS max_rev FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ?
""",
(low_id, high_id),
).fetchone()
return int(row["max_rev"] or 0) + 1
def create_opening_balance(
connection: sqlite3.Connection,
company_a: int,
company_b: int,
amount: object,
reason: str,
actor: sqlite3.Row,
*,
currency: str = "CNY",
viewer_company_id: int | None = None,
) -> dict[str, object]:
low_id, high_id = normalize_pair(company_a, company_b)
decimal_amount = _parse_decimal(amount)
if viewer_company_id is not None and viewer_company_id == high_id:
decimal_amount = -decimal_amount
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("录入期初必须填写原因。")
existing = _current_opening_revision(connection, low_id, high_id)
if existing is not None and existing["status"] == "draft":
raise ConflictError("该公司对已有待确认期初,请先确认或作废后再录入。")
if existing is not None and existing["status"] == "confirmed":
raise ConflictError("该对公司已有确认期初,请使用修订。")
revision = _next_revision(connection, low_id, high_id)
now = utc_now()
with connection:
cursor = connection.execute(
"""
INSERT INTO opening_balance_revisions (
company_id_low, company_id_high, amount, currency, revision,
status, reason, actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
""",
(
low_id,
high_id,
str(decimal_amount),
currency,
revision,
reason,
actor["id"],
actor["username"],
now,
),
)
row_id = int(cursor.lastrowid)
master_data.record_change(
connection,
"opening_balance",
row_id,
"create_draft",
None,
{"company_id_low": low_id, "company_id_high": high_id, "amount": str(decimal_amount)},
reason,
actor,
)
return opening_balance_payload(connection, row_id)
def confirm_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] != "draft":
raise ConflictError("只能确认待确认状态的期初。")
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("确认期初必须填写原因。")
with connection:
connection.execute(
"""
UPDATE opening_balance_revisions SET status = 'confirmed', reason = ?
WHERE id = ?
""",
(reason, revision_id),
)
master_data.record_change(
connection,
"opening_balance",
revision_id,
"confirm",
{"status": "draft"},
{"status": "confirmed"},
reason,
actor,
)
return opening_balance_payload(connection, revision_id)
def revise_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
amount: object,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] != "confirmed":
raise ConflictError("只能修订已确认期初。")
decimal_amount = _parse_decimal(amount)
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("修订期初必须填写原因。")
low_id = int(row["company_id_low"])
high_id = int(row["company_id_high"])
revision = _next_revision(connection, low_id, high_id)
now = utc_now()
with connection:
connection.execute(
"UPDATE opening_balance_revisions SET status = 'superseded' WHERE id = ?",
(revision_id,),
)
cursor = connection.execute(
"""
INSERT INTO opening_balance_revisions (
company_id_low, company_id_high, amount, currency, revision,
status, reason, actor_user_id, actor_username, supersedes_id, created_at
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
""",
(
low_id,
high_id,
str(decimal_amount),
row["currency"],
revision,
reason,
actor["id"],
actor["username"],
revision_id,
now,
),
)
new_id = int(cursor.lastrowid)
master_data.record_change(
connection,
"opening_balance",
new_id,
"revise",
{"amount": row["amount"], "revision_id": revision_id},
{"amount": str(decimal_amount), "revision_id": new_id},
reason,
actor,
)
return opening_balance_payload(connection, new_id)
def void_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] not in ("draft", "confirmed"):
raise ConflictError("该期初已作废或已被替代。")
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("作废期初必须填写原因。")
with connection:
connection.execute(
"UPDATE opening_balance_revisions SET status = 'void', reason = ? WHERE id = ?",
(reason, revision_id),
)
master_data.record_change(
connection,
"opening_balance",
revision_id,
"void",
{"status": row["status"]},
{"status": "void"},
reason,
actor,
)
return opening_balance_payload(connection, revision_id)
def opening_balance_payload(connection: sqlite3.Connection, revision_id: int) -> dict[str, object]:
row = connection.execute(
"""
SELECT r.*, cl.name AS company_low_name, ch.name AS company_high_name
FROM opening_balance_revisions r
JOIN companies cl ON cl.id = r.company_id_low
JOIN companies ch ON ch.id = r.company_id_high
WHERE r.id = ?
""",
(revision_id,),
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
amount = _parse_decimal(row["amount"])
return {
"id": row["id"],
"company_id_low": row["company_id_low"],
"company_id_high": row["company_id_high"],
"company_low_name": row["company_low_name"],
"company_high_name": row["company_high_name"],
"amount": str(amount),
"currency": row["currency"],
"revision": row["revision"],
"status": row["status"],
"reason": row["reason"],
"actor_username": row["actor_username"],
"supersedes_id": row["supersedes_id"],
"created_at": row["created_at"],
"direction_low": "receivable" if amount >= 0 else "payable",
}
def list_opening_balances(connection: sqlite3.Connection) -> list[dict[str, object]]:
rows = connection.execute(
"""
SELECT r.id FROM opening_balance_revisions r
JOIN (
SELECT company_id_low, company_id_high, MAX(revision) AS max_rev
FROM opening_balance_revisions
WHERE status IN ('draft', 'confirmed', 'void')
GROUP BY company_id_low, company_id_high
) latest ON latest.company_id_low = r.company_id_low
AND latest.company_id_high = r.company_id_high
AND latest.max_rev = r.revision
WHERE r.status IN ('draft', 'confirmed', 'void')
ORDER BY r.company_id_low, r.company_id_high
"""
).fetchall()
return [opening_balance_payload(connection, row["id"]) for row in rows]
def confirmed_opening_amount(
connection: sqlite3.Connection, low_id: int, high_id: int
) -> Decimal | None:
row = connection.execute(
"""
SELECT amount FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ? AND status = 'confirmed'
ORDER BY revision DESC LIMIT 1
""",
(low_id, high_id),
).fetchone()
if row is None:
return None
return _parse_decimal(row["amount"])
def _date_add(day: str, delta: int) -> str:
return (date.fromisoformat(day) + timedelta(days=delta)).isoformat()
def _merge_intervals(intervals: list[tuple[str, str]]) -> list[tuple[str, str]]:
if not intervals:
return []
sorted_intervals = sorted(intervals, key=lambda item: item[0])
merged = [sorted_intervals[0]]
for start, end in sorted_intervals[1:]:
last_start, last_end = merged[-1]
if date.fromisoformat(start) <= date.fromisoformat(_date_add(last_end, 1)):
if date.fromisoformat(end) > date.fromisoformat(last_end):
merged[-1] = (last_start, end)
else:
merged.append((start, end))
return merged
def _account_coverage_intervals(
connection: sqlite3.Connection, account: sqlite3.Row
) -> list[tuple[str, str]]:
rows = connection.execute(
"""
SELECT MIN(substr(r.transaction_at, 1, 10)) AS interval_start,
MAX(substr(r.transaction_at, 1, 10)) AS interval_end
FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
JOIN import_batches b ON b.id = s.import_batch_id
WHERE b.upload_bank_account_id = ?
OR r.own_account = ?
GROUP BY s.id
ORDER BY interval_start
""",
(account["id"], account["account_number"]),
).fetchall()
return [
(row["interval_start"], row["interval_end"])
for row in rows
if row["interval_start"] and row["interval_end"]
]
def _account_transaction_dates(
connection: sqlite3.Connection, account: sqlite3.Row
) -> list[str]:
intervals = _account_coverage_intervals(connection, account)
dates: list[str] = []
for start, end in intervals:
current = date.fromisoformat(start)
end_day = date.fromisoformat(end)
while current <= end_day:
dates.append(current.isoformat())
current += timedelta(days=1)
return dates
def _dates_to_intervals(dates: list[str]) -> list[tuple[str, str]]:
if not dates:
return []
intervals: list[tuple[str, str]] = []
start = dates[0]
prev = dates[0]
for current in dates[1:]:
if date.fromisoformat(current) == date.fromisoformat(prev) + timedelta(days=1):
prev = current
continue
intervals.append((start, prev))
start = current
prev = current
intervals.append((start, prev))
return intervals
def _detect_gaps(
required_start: str,
required_end: str,
covered: list[tuple[str, str]],
) -> list[tuple[str, str, str]]:
if required_start > required_end:
return []
gaps: list[tuple[str, str, str]] = []
if not covered:
gaps.append((required_start, required_end, "head"))
return gaps
merged = _merge_intervals(covered)
first_start, first_end = merged[0]
if required_start < first_start:
gaps.append((required_start, _date_add(first_start, -1), "head"))
for index in range(len(merged) - 1):
_, left_end = merged[index]
right_start, _ = merged[index + 1]
gap_start = _date_add(left_end, 1)
gap_end = _date_add(right_start, -1)
if gap_start <= gap_end:
gaps.append((gap_start, gap_end, "mid"))
last_start, last_end = merged[-1]
tail_start = _date_add(last_end, 1)
gap_from = max(tail_start, required_start)
if gap_from <= required_end:
kind = "tail" if last_end >= required_start else "head"
gaps.append((gap_from, required_end, kind))
return gaps
def detect_account_gaps(
connection: sqlite3.Connection,
account: sqlite3.Row,
*,
start_date: str | None,
today: str | None = None,
) -> list[tuple[str, str, str]]:
if account["status"] != "active":
return []
today = today or utc_today()
effective_from = account["effective_from"] or start_date or today
effective_to = account["effective_to"] or today
required_start = max(filter(None, [start_date, effective_from]))
required_end = min(today, effective_to)
if not master_data.is_usable(account, required_start):
return []
intervals = _account_coverage_intervals(connection, account)
return _detect_gaps(required_start, required_end, intervals)
def recalculate_coverage_gaps(connection: sqlite3.Connection) -> int:
start_date = get_calculation_start_date(connection)
today = utc_today()
accounts = connection.execute(
"SELECT * FROM bank_accounts WHERE status = 'active'"
).fetchall()
rebuilt = 0
with connection:
for account in accounts:
connection.execute(
"""
DELETE FROM coverage_gaps
WHERE bank_account_id = ? AND status = 'open'
""",
(account["id"],),
)
for gap_start, gap_end, gap_kind in detect_account_gaps(
connection, account, start_date=start_date, today=today
):
connection.execute(
"""
INSERT INTO coverage_gaps (
bank_account_id, gap_start, gap_end, gap_kind,
status, first_detected_at
) VALUES (?, ?, ?, ?, 'open', ?)
ON CONFLICT(bank_account_id, gap_start, gap_end) DO UPDATE SET
gap_kind = excluded.gap_kind,
status = CASE coverage_gaps.status
WHEN 'closed_attested' THEN 'closed_attested'
ELSE 'open'
END
""",
(account["id"], gap_start, gap_end, gap_kind, utc_now()),
)
rebuilt += 1
return rebuilt
def coverage_gap_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
account = master_data.get_account(connection, row["bank_account_id"])
if account is None:
raise ValueError("账户不存在。")
company = connection.execute(
"SELECT name FROM companies WHERE id = ?", (account["company_id"],)
).fetchone()
start = date.fromisoformat(row["gap_start"])
end = date.fromisoformat(row["gap_end"])
gap_days = (end - start).days + 1
pending = connection.execute(
"""
SELECT id FROM no_business_attestations
WHERE bank_account_id = ?
AND gap_start = ? AND gap_end = ?
AND status = 'pending'
ORDER BY id DESC LIMIT 1
""",
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
).fetchone()
return {
"id": row["id"],
"bank_account_id": row["bank_account_id"],
"company_id": account["company_id"],
"company_name": company["name"] if company else None,
"account_number_masked": master_data.mask_account_number(account["account_number"]),
"gap_start": row["gap_start"],
"gap_end": row["gap_end"],
"gap_kind": row["gap_kind"],
"gap_days": gap_days,
"day_count": gap_days,
"status": row["status"],
"first_detected_at": row["first_detected_at"],
"pending_attestation_id": pending["id"] if pending else None,
}
def list_coverage_gaps(
connection: sqlite3.Connection,
*,
company_id: int | None = None,
status: str | None = None,
) -> list[dict[str, object]]:
clauses: list[str] = []
params: list[object] = []
if company_id is not None:
clauses.append("ba.company_id = ?")
params.append(company_id)
if status is not None:
clauses.append("g.status = ?")
params.append(status)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
rows = connection.execute(
f"""
SELECT g.* FROM coverage_gaps g
JOIN bank_accounts ba ON ba.id = g.bank_account_id
{where}
ORDER BY g.gap_start, g.bank_account_id
""",
params,
).fetchall()
return [coverage_gap_payload(connection, row) for row in rows]
def submit_no_business_attestation(
connection: sqlite3.Connection,
*,
company_id: int,
bank_account_id: int,
gap_start: str,
gap_end: str,
reason: str,
evidence: str | None,
actor: sqlite3.Row,
) -> dict[str, object]:
gap_start = master_data.validate_date(gap_start, "断档起始日", required=True)
gap_end = master_data.validate_date(gap_end, "断档结束日", required=True)
if gap_start > gap_end:
raise ValueError("断档起始日不能晚于结束日。")
reason = str(reason or "").strip()
if len(reason) < 5:
raise ValueError("无业务说明至少 5 个字符。")
account = master_data.get_account(connection, bank_account_id)
if account["company_id"] != company_id:
raise ValueError("只能为本公司账户提交说明。")
now = utc_now()
with connection:
cursor = connection.execute(
"""
INSERT INTO no_business_attestations (
bank_account_id, gap_start, gap_end, reason, evidence,
submitted_by, company_id, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)
""",
(
bank_account_id,
gap_start,
gap_end,
reason,
evidence,
actor["id"],
company_id,
now,
),
)
attestation_id = int(cursor.lastrowid)
return attestation_payload(connection, attestation_id)
def review_no_business_attestation(
connection: sqlite3.Connection,
attestation_id: int,
decision: str,
review_reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
).fetchone()
if row is None:
raise ValueError("无业务说明不存在。")
if row["status"] != "pending":
raise ConflictError("该说明已审核。")
review_reason = str(review_reason or "").strip()
if decision not in ("approve", "reject"):
raise ValueError("审核决定无效。")
if len(review_reason) < 2:
raise ValueError("审核必须填写理由。")
status = "approved" if decision == "approve" else "rejected"
now = utc_now()
with connection:
connection.execute(
"""
UPDATE no_business_attestations
SET status = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ?
WHERE id = ?
""",
(status, actor["id"], now, review_reason, attestation_id),
)
if status == "approved":
# Exact match first so the attested row survives recalculate's
# "DELETE ... status='open'" and stays closed_attested.
connection.execute(
"""
UPDATE coverage_gaps
SET status = 'closed_attested'
WHERE bank_account_id = ?
AND gap_start = ? AND gap_end = ?
""",
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
)
recalculate_coverage_gaps(connection)
if status == "approved":
# Overlap match: if recalculate shifts boundaries, still close
# any open gap that intersects the attested interval.
connection.execute(
"""
UPDATE coverage_gaps
SET status = 'closed_attested'
WHERE bank_account_id = ?
AND status = 'open'
AND gap_start <= ?
AND gap_end >= ?
""",
(row["bank_account_id"], row["gap_end"], row["gap_start"]),
)
return attestation_payload(connection, attestation_id)
def attestation_payload(connection: sqlite3.Connection, attestation_id: int) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
).fetchone()
if row is None:
raise ValueError("无业务说明不存在。")
account = master_data.get_account(connection, row["bank_account_id"])
if account is None:
raise ValueError("账户不存在。")
return {
"id": row["id"],
"bank_account_id": row["bank_account_id"],
"company_id": row["company_id"],
"gap_start": row["gap_start"],
"gap_end": row["gap_end"],
"reason": row["reason"],
"evidence": row["evidence"],
"status": row["status"],
"review_reason": row["review_reason"],
"reviewed_at": row["reviewed_at"],
"created_at": row["created_at"],
"account_number_masked": master_data.mask_account_number(account["account_number"]),
}
def list_change_log(connection: sqlite3.Connection, limit: int = 100) -> list[dict[str, object]]:
rows = connection.execute(
"""
SELECT * FROM master_data_changes
WHERE entity_type IN ('system_setting', 'opening_balance')
ORDER BY id DESC LIMIT ?
""",
(limit,),
).fetchall()
items: list[dict[str, object]] = []
for row in rows:
before = json.loads(row["before_json"]) if row["before_json"] else None
after = json.loads(row["after_json"]) if row["after_json"] else None
target = "起算日"
if row["entity_type"] == "opening_balance":
target = f"期初 #{row['entity_id']}"
items.append(
{
"id": row["id"],
"created_at": row["created_at"],
"actor_username": row["actor_username"],
"action": row["action"],
"target": target,
"before": before,
"after": after,
"reason": row["reason"],
}
)
return items
def _pair_net_change(
connection: sqlite3.Connection,
viewer_id: int,
counterparty_id: int,
*,
cutoff: str | None,
start_date: str | None,
) -> Decimal:
cutoff_where = ""
params: list[object] = [viewer_id, counterparty_id, counterparty_id, viewer_id]
if cutoff:
cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?"
params.append(cutoff)
start_where = ""
if start_date:
start_where = "AND substr(d.effective_at, 1, 10) >= ?"
params.append(start_date)
rows = connection.execute(
f"""
SELECT d.amount, payer.company_id AS payer_id, payee.company_id AS payee_id
FROM eligible_intercompany_events e
JOIN transfer_match_decisions d ON d.id = e.decision_id
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE (
(payer.company_id = ? AND payee.company_id = ?)
OR (payer.company_id = ? AND payee.company_id = ?)
)
{cutoff_where}
{start_where}
""",
params,
).fetchall()
total = Decimal("0")
for row in rows:
amount = _parse_decimal(row["amount"])
if row["payee_id"] == viewer_id:
total += amount
elif row["payer_id"] == viewer_id:
total -= amount
return total
def pair_has_confirmed_opening(
connection: sqlite3.Connection, company_a: int, company_b: int
) -> bool:
low_id, high_id = normalize_pair(company_a, company_b)
return confirmed_opening_amount(connection, low_id, high_id) is not None
def compute_pair_balance(
connection: sqlite3.Connection,
viewer_id: int,
counterparty_id: int,
*,
cutoff: str | None = None,
) -> dict[str, object]:
cutoff = cutoff or utc_today()
cutoff = master_data.validate_date(cutoff, "截止日", required=True)
start_date = get_calculation_start_date(connection)
low_id, high_id = normalize_pair(viewer_id, counterparty_id)
opening_amount: Decimal | None = None
if start_date and pair_has_confirmed_opening(connection, viewer_id, counterparty_id):
stored = confirmed_opening_amount(connection, low_id, high_id)
assert stored is not None
opening_amount = signed_from_viewer(viewer_id, low_id, high_id, stored)
net_change = _pair_net_change(
connection,
viewer_id,
counterparty_id,
cutoff=cutoff,
start_date=start_date,
)
unresolved = matching.unresolved_amounts(connection, viewer_id, cutoff=cutoff)
pending_total = Decimal("0")
for row in unresolved:
pending_total += _parse_decimal(row["amount"] or "0")
basis = "full" if opening_amount is not None and start_date else "net_change"
payload: dict[str, object] = {
"viewer_company_id": viewer_id,
"counterparty_company_id": counterparty_id,
"effective_at": cutoff,
"calculation_start_date": start_date,
"net_change": str(net_change),
"basis": basis,
"pending_unconfirmed": str(pending_total),
"currency": "CNY",
}
if basis == "full" and opening_amount is not None:
closing = opening_amount + net_change
payload["opening"] = str(opening_amount)
payload["closing"] = str(closing)
payload["opening_effective_at"] = _date_add(start_date, -1)
return payload
def list_company_counterparties(
connection: sqlite3.Connection, company_id: int
) -> list[int]:
rows = connection.execute(
"""
SELECT DISTINCT CASE
WHEN payer.company_id = ? THEN payee.company_id
ELSE payer.company_id
END AS other_id
FROM eligible_intercompany_events e
JOIN transfer_decision_participants payer ON payer.decision_id = e.decision_id AND payer.role = 'payer'
JOIN transfer_decision_participants payee ON payee.decision_id = e.decision_id AND payee.role = 'payee'
WHERE payer.company_id = ? OR payee.company_id = ?
""",
(company_id, company_id, company_id),
).fetchall()
return [int(row["other_id"]) for row in rows if row["other_id"] is not None]
def compute_company_balances(
connection: sqlite3.Connection,
company_id: int,
*,
cutoff: str | None = None,
) -> dict[str, object]:
cutoff = cutoff or utc_today()
start_date = get_calculation_start_date(connection)
counterparties = list_company_counterparties(connection, company_id)
pairs = [
compute_pair_balance(connection, company_id, other_id, cutoff=cutoff)
for other_id in counterparties
]
complete = bool(start_date) and all(item["basis"] == "full" for item in pairs)
return {
"company_id": company_id,
"cutoff": cutoff,
"calculation_start_date": start_date,
"basis": "full" if complete and pairs else "net_change",
"pairs": pairs,
}
def opening_coverage_summary(connection: sqlite3.Connection) -> dict[str, int]:
companies = connection.execute(
"SELECT COUNT(*) AS n FROM companies"
).fetchone()["n"]
recorded = connection.execute(
"""
SELECT COUNT(DISTINCT company_id_low || ':' || company_id_high) AS n
FROM opening_balance_revisions WHERE status = 'confirmed'
"""
).fetchone()["n"]
return {"company_count": companies, "confirmed_pair_count": recorded}
+20 -6
View File
@@ -20,7 +20,7 @@ from decimal import Decimal, InvalidOperation
import re
import sqlite3
from . import matching, settings
from . import calculation, matching, settings
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_ZERO = Decimal("0.00")
@@ -72,7 +72,8 @@ def _window_bounds(
connection: sqlite3.Connection, as_of: str | None
) -> tuple[str, str]:
end = _validate_date(as_of, "as_of") if as_of else today_shanghai()
start = settings.get_settings(connection).get("start_date") or "2026-01-01"
calc_start = calculation.get_calculation_start_date(connection)
start = calc_start or settings.get_settings(connection).get("start_date") or "2026-01-01"
start = _validate_date(start, "start_date")
if start > end:
# Opening / start-date plumbing may lag; clamp rather than 500.
@@ -282,15 +283,28 @@ def company_intercompany_summary(
}
)
# Enrich window with calculation-basis opening/ending when configured.
balances = calculation.compute_company_balances(
connection, company_id, cutoff=end
)
has_opening = balances.get("basis") == "full"
opening_total = _ZERO
ending_total = _ZERO
if has_opening:
for pair in balances.get("pairs") or []:
opening_total += _as_decimal(pair.get("opening") or "0")
ending_total += _as_decimal(pair.get("closing") or "0")
return {
"own_company": own,
"window": {
"start": start,
"end": end,
"has_opening": False,
# Reserved for opening-balance rollout; callers must not invent balances.
"opening": None,
"ending": None,
"has_opening": has_opening,
"opening": _money(opening_total) if has_opening else None,
"ending": _money(ending_total) if has_opening else None,
"basis": balances.get("basis"),
"calculation_start_date": balances.get("calculation_start_date"),
},
"confirmed": {
"outflow_total": _money(outflow),
+165
View File
@@ -846,6 +846,171 @@ MIGRATIONS: tuple[Migration, ...] = (
DROP TABLE IF EXISTS system_settings;
""",
),
Migration(
version=8,
name="0008_calculation_window",
# HEL-194/202: opening balances, coverage gaps, no-business attestations.
# Reuses system_settings / system_setting_changes from 0007; does not
# recreate them. Extends master_data_changes CHECK and overlays the
# calculation_start_date filter onto eligible_intercompany_events.
up="""
CREATE TABLE closed_periods (
year_month TEXT PRIMARY KEY,
closed_at TEXT NOT NULL,
closed_by INTEGER REFERENCES users (id)
);
CREATE TABLE opening_balance_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id_low INTEGER NOT NULL REFERENCES companies (id),
company_id_high INTEGER NOT NULL REFERENCES companies (id),
amount TEXT NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
revision INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('draft', 'confirmed', 'superseded', 'void')),
reason TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
supersedes_id INTEGER REFERENCES opening_balance_revisions (id),
created_at TEXT NOT NULL,
UNIQUE (company_id_low, company_id_high, revision),
CHECK (company_id_low < company_id_high)
);
CREATE TABLE coverage_gaps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
gap_start TEXT NOT NULL,
gap_end TEXT NOT NULL,
gap_kind TEXT NOT NULL CHECK (gap_kind IN ('head', 'mid', 'tail')),
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed_attested')),
first_detected_at TEXT NOT NULL,
UNIQUE (bank_account_id, gap_start, gap_end)
);
CREATE TABLE no_business_attestations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
gap_start TEXT NOT NULL,
gap_end TEXT NOT NULL,
reason TEXT NOT NULL,
evidence TEXT,
submitted_by INTEGER REFERENCES users (id),
company_id INTEGER NOT NULL REFERENCES companies (id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_opening_balance_pair ON opening_balance_revisions (company_id_low, company_id_high);
CREATE INDEX idx_coverage_gaps_account ON coverage_gaps (bank_account_id);
CREATE INDEX idx_attestations_company ON no_business_attestations (company_id);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN (
'company', 'user', 'bank_account', 'account_alias',
'personal_transit_mapping', 'system_setting', 'opening_balance'
)),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
DROP VIEW IF EXISTS eligible_intercompany_events;
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1)
AND (
(SELECT value FROM system_settings WHERE key = 'calculation_start_date') IS NULL
OR substr(d.effective_at, 1, 10) >= (
SELECT value FROM system_settings WHERE key = 'calculation_start_date'
)
);
""",
down="""
DROP VIEW IF EXISTS eligible_intercompany_events;
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN (
'company', 'user', 'bank_account', 'account_alias',
'personal_transit_mapping'
)),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes
WHERE entity_type NOT IN ('system_setting', 'opening_balance');
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
DROP INDEX IF EXISTS idx_attestations_company;
DROP INDEX IF EXISTS idx_coverage_gaps_account;
DROP INDEX IF EXISTS idx_opening_balance_pair;
DROP TABLE IF EXISTS no_business_attestations;
DROP TABLE IF EXISTS coverage_gaps;
DROP TABLE IF EXISTS opening_balance_revisions;
DROP TABLE IF EXISTS closed_periods;
""",
),
)
+1 -1
View File
@@ -1523,7 +1523,7 @@ def unresolved_amounts(
count as unresolved; paired intercompany, same-company and external events
are resolved classifications and never appear here.
"""
cutoff_where = "AND d.effective_at <= ?" if cutoff else ""
cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?" if cutoff else ""
params: list[object] = []
if cutoff:
params.append(cutoff)
+542
View File
@@ -0,0 +1,542 @@
"""Tests for calculation window: start date, opening balances, coverage gaps."""
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import json
import tempfile
import threading
import unittest
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
from test_server_auth import Client, as_json
class CalculationBase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = Path(self.temp_dir.name) / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
self.admin = self._admin()
self.company_a = self._company("甲公司")
self.company_b = self._company("乙公司")
self.account_a = self._approved_account(self.company_a, "6222000000000001")
self.account_b = self._approved_account(self.company_b, "6222000000000002")
def _admin(self):
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
return self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
def _company(self, name: str) -> int:
with self.connection:
cursor = self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)",
(name, utc_now(), utc_now()),
)
return int(cursor.lastrowid)
def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"):
account = master_data.submit_bank_account(
self.connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date=start,
actor=None,
)
return master_data.review_bank_account(
self.connection, account["id"], "approve", None, self.admin,
effective_from=start,
)
def add_confirmed_row(
self,
company_id: int,
*,
account_id: int,
own_account: str,
at: str,
income: str = "0",
expense: str = "0",
cp_account: str | None = None,
sheet: str = "流水",
source_row: int = 1,
) -> int:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(f"sha-{at}-{own_account}-{source_row}", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, transaction_count, warnings, created_at
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, sheet, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet, sheet_batch_id, utc_now()),
)
cursor = self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, counterparty_account, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(sheet_batch_id, source_row, at, income, expense, own_account, cp_account, utc_now()),
)
return int(cursor.lastrowid)
def add_confirmed_batch_range(
self,
company_id: int,
*,
account_id: int,
own_account: str,
start: str,
end: str,
sheet: str,
) -> None:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(f"sha-batch-{sheet}", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, transaction_count, warnings, created_at
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, 2, '[]', ?)
""",
(batch_id, sheet, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet, sheet_batch_id, utc_now()),
)
for source_row, day in ((1, start), (2, end)):
self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, created_at
) VALUES (?, ?, ?, '0', '0', ?, ?)
""",
(sheet_batch_id, source_row, f"{day}T10:00:00", own_account, utc_now()),
)
class StartDateTests(CalculationBase):
def test_set_start_date_records_change(self) -> None:
result = calculation.set_calculation_start_date(
self.connection, "2026-01-01", "首次设定", self.admin
)
self.assertEqual("2026-01-01", result["calculation_start_date"])
row = self.connection.execute(
"SELECT 1 FROM master_data_changes WHERE entity_type = 'system_setting'"
).fetchone()
self.assertIsNotNone(row)
def test_locked_after_closed_period(self) -> None:
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "首次设定", self.admin
)
with self.connection:
self.connection.execute(
"INSERT INTO closed_periods (year_month, closed_at, closed_by) VALUES ('2026-01', ?, ?)",
(utc_now(), self.admin["id"]),
)
with self.assertRaises(calculation.LockedError):
calculation.set_calculation_start_date(
self.connection, "2026-02-01", "尝试修改", self.admin
)
class OpeningBalanceTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "测试起算日", self.admin
)
def test_bilateral_conservation_on_storage(self) -> None:
item = calculation.create_opening_balance(
self.connection,
self.company_a,
self.company_b,
"100.00",
"期初录入",
self.admin,
viewer_company_id=self.company_a,
)
low, high = calculation.normalize_pair(self.company_a, self.company_b)
self.assertEqual(low, item["company_id_low"])
stored = calculation.confirmed_opening_amount(self.connection, low, high)
self.assertIsNone(stored)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认期初", self.admin
)
stored = calculation.confirmed_opening_amount(self.connection, low, high)
self.assertEqual(Decimal("100.00"), stored)
from_b = calculation.signed_from_viewer(self.company_b, low, high, stored)
self.assertEqual(Decimal("-100.00"), from_b)
def test_confirmed_requires_revision_not_overwrite(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "50", "录入", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
with self.assertRaises(calculation.ConflictError):
calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "80", "重复录入", self.admin
)
revised = calculation.revise_opening_balance(
self.connection, item["id"], "80", "修订", self.admin
)
self.assertEqual("draft", revised["status"])
class CoverageGapTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-06-01", "起算", self.admin
)
def test_adjacent_intervals_no_mid_gap(self) -> None:
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-06-21",
end="2026-07-21",
sheet="批次A",
)
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-07-22",
end="2026-08-21",
sheet="批次B",
)
calculation.recalculate_coverage_gaps(self.connection)
mids = self.connection.execute(
"SELECT * FROM coverage_gaps WHERE gap_kind = 'mid'"
).fetchall()
self.assertEqual([], mids)
def test_missing_day_mid_gap(self) -> None:
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-06-21",
end="2026-07-21",
sheet="批次A",
)
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-07-23",
end="2026-08-21",
sheet="批次B",
)
calculation.recalculate_coverage_gaps(self.connection)
gap = self.connection.execute(
"""
SELECT gap_start, gap_end FROM coverage_gaps
WHERE gap_kind = 'mid' AND gap_start = '2026-07-22'
"""
).fetchone()
self.assertIsNotNone(gap)
self.assertEqual("2026-07-22", gap["gap_end"])
def test_attestation_closes_gap_without_bank_row(self) -> None:
self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2026-06-21T10:00:00",
)
calculation.recalculate_coverage_gaps(self.connection)
gap = self.connection.execute(
"SELECT * FROM coverage_gaps WHERE status = 'open'"
).fetchone()
self.assertIsNotNone(gap)
before_rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"]
cashier_id = auth.create_user(
self.connection, "cashier-a", "CashierA123", "company", self.company_a
)
cashier = self.connection.execute(
"SELECT * FROM users WHERE id = ?", (cashier_id,)
).fetchone()
att = calculation.submit_no_business_attestation(
self.connection,
company_id=self.company_a,
bank_account_id=self.account_a["id"],
gap_start=gap["gap_start"],
gap_end=gap["gap_end"],
reason="当日账户无资金往来",
evidence=None,
actor=cashier,
)
calculation.review_no_business_attestation(
self.connection, att["id"], "approve", "审核通过", self.admin
)
after_rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"]
self.assertEqual(before_rows, after_rows)
closed = self.connection.execute(
"SELECT status FROM coverage_gaps WHERE id = ?", (gap["id"],)
).fetchone()
self.assertEqual("closed_attested", closed["status"])
class BalanceBasisTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "起算", self.admin
)
def test_without_opening_returns_net_change(self) -> None:
row_a = self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2026-01-05T10:00:00",
expense="100.00",
cp_account="6222000000000002",
)
row_b = self.add_confirmed_row(
self.company_b,
account_id=self.account_b["id"],
own_account="6222000000000002",
at="2026-01-05T11:00:00",
income="100.00",
cp_account="6222000000000001",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("net_change", balance["basis"])
self.assertNotIn("closing", balance)
def test_with_opening_returns_full_basis(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "200", "录入", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("full", balance["basis"])
self.assertEqual("200", balance["opening"])
self.assertEqual("200", balance["closing"])
def test_pre_start_events_excluded(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "0", "零期初", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
row_a = self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2025-12-31T10:00:00",
expense="50.00",
cp_account="6222000000000002",
)
row_b = self.add_confirmed_row(
self.company_b,
account_id=self.account_b["id"],
own_account="6222000000000002",
at="2025-12-31T11:00:00",
income="50.00",
cp_account="6222000000000001",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("0", balance["net_change"])
def test_cutoff_day_event_is_included(self) -> None:
"""effective_at with time on the cutoff date must still count."""
from test_matching import MatchingBase
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "0", "零期初", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
helper = object.__new__(MatchingBase)
helper.connection = self.connection
row_a = helper.add_row(
self.company_a,
own_account="6222000000000001",
cp_account="6222000000000002",
expense="80.00",
at="2026-01-31T10:00:00",
)
matching.reconcile_rows(self.connection, [row_a])
row_b = helper.add_row(
self.company_b,
own_account="6222000000000002",
cp_account="6222000000000001",
income="80.00",
at="2026-01-31T11:00:00",
)
matching.reconcile_rows(self.connection, [row_b])
eligible = self.connection.execute(
"SELECT effective_at, amount FROM eligible_intercompany_events"
).fetchall()
self.assertEqual(1, len(eligible))
self.assertEqual("2026-01-31T10:00:00", eligible[0]["effective_at"])
# Full-timestamp string compare wrongly excludes the cutoff day.
self.assertEqual(
[],
self.connection.execute(
"""
SELECT 1 FROM eligible_intercompany_events
WHERE effective_at <= '2026-01-31'
"""
).fetchall(),
)
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("-80.00", balance["net_change"])
self.assertEqual("-80.00", balance["closing"])
class CalculationApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
cls.db_path = Path(cls.temp_dir.name) / "app.db"
cls.storage_dir = Path(cls.temp_dir.name) / "files"
cls.storage_dir.mkdir()
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage_dir
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection, "group-admin", "AdminPass123", "admin",
must_change_password=False,
)
company_id = master_data.create_company(connection, "甲公司", None, None, None)
auth.create_user(
connection, "cashier-a", "CashierA123", "company", company_id,
must_change_password=False,
)
connection.close()
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.AppHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.client = Client("127.0.0.1", cls.port)
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.temp_dir.cleanup()
def test_company_cannot_call_admin_start_date(self) -> None:
self.client.post_json("/api/login", {
"username": "cashier-a", "password": "CashierA123", "portal": "company",
})
status, _, _ = self.client.request(
"PUT",
"/api/admin/settings/calculation-start",
body=json.dumps(
{"calculation_start_date": "2026-01-01", "reason": "越权"}
).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
self.assertEqual(403, status)
def test_admin_can_set_start_date(self) -> None:
client = Client("127.0.0.1", self.port)
client.post_json("/api/login", {
"username": "group-admin", "password": "AdminPass123", "portal": "admin",
})
status, _, body = client.request(
"PUT",
"/api/admin/settings/calculation-start",
body=json.dumps(
{"calculation_start_date": "2026-01-01", "reason": "初始化"}
).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
self.assertEqual(200, status)
data = as_json(body)
self.assertEqual("2026-01-01", data["calculation_start_date"])
if __name__ == "__main__":
unittest.main()
+221
View File
@@ -0,0 +1,221 @@
"""HEL-174: 公司端往来确认完成态用 success 绿,待确认保留 warn 黄。"""
from __future__ import annotations
import re
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover - 默认测试环境无浏览器依赖
sync_playwright = None
class ConfirmStatusSourceContractTests(unittest.TestCase):
"""不依赖浏览器:锁住完成态切绿 / 待确认仍黄的实现契约。"""
def test_app_js_toggles_success_green_when_pending_zero(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn('pill ${total ? "pill-warn" : "pill-success"}', js)
self.assertIn('flowStep.classList.toggle("doing", pending > 0)', js)
self.assertIn('flowStep.classList.toggle("done", pending === 0)', js)
self.assertIn('notice.classList.toggle("warn", pending > 0)', js)
self.assertIn('notice.classList.toggle("success", pending === 0)', js)
# 文案:完成=已完成,不把待确认一并改绿
self.assertRegex(js, r'status\.textContent = total \? `\$\{total\} 项待处理` : "已完成"')
self.assertRegex(js, r'flowState\.textContent = pending \? `待处理 \$\{pending\} 笔` : "已完成"')
def test_design_tokens_map_done_to_success_doing_to_warn(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn("--success:", css)
self.assertIn("--warn:", css)
self.assertIn(".flow-step.done .fs-dot { background: var(--success); }", css)
self.assertIn(".flow-step.done .fs-state { color: var(--success); }", css)
self.assertIn(".flow-step.doing .fs-dot { background: var(--warn);", css)
self.assertIn(".pill-success { background: var(--success-soft); color: var(--success); }", css)
self.assertIn(".pill-warn { background: var(--warn-soft);", css)
self.assertIn(".notice.success { background: var(--success-soft);", css)
self.assertIn(".notice.warn { background: var(--warn-soft);", css)
def test_company_html_exposes_flow_step_and_cache_bust(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
self.assertIn('id="workspaceConfirmState"', html)
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=12", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
def _extract_fn(source: str, name: str) -> str:
marker = f"function {name}("
start = source.index(marker)
depth = 0
for i, ch in enumerate(source[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError(f"未能截取 function {name}")
def _chromium_available() -> bool:
"""本机缺 libatk 等系统库时 chromium 无法启动。"""
try:
import ctypes.util
return bool(ctypes.util.find_library("atk-1.0"))
except Exception:
return False
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验")
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过浏览器色值校验")
class ConfirmStatusDomTests(unittest.TestCase):
"""真实浏览器:pending>0 为黄,pending=0 为绿。"""
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
app_js = (WEB / "app.js").read_text(encoding="utf-8")
cls.inject_js = "\n".join(
[
"const state = {};",
"function $(sel, root) { return (root || document).querySelector(sel); }",
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
_extract_fn(app_js, "formatWorkspaceAmount"),
_extract_fn(app_js, "applyCompanyWorkspace"),
]
)
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def _open_fixture(self, page):
page.goto(f"{self.base}/design-system.css", wait_until="domcontentloaded")
page.set_content(
f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body data-portal="company">
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
<div class="card" id="workspaceTodos">
<div class="card-head">
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">…</span></span>
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
</div>
<div id="workspaceTodoList"></div>
<div class="table-foot" id="workspaceTodoFoot"><span>…</span></div>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">账期流程<span class="sub" id="workspaceFlowSub">…</span></span>
</div>
<div class="flow">
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
<div class="fs-meta" id="workspaceConfirmMeta">…</div>
</a>
</div>
</div>
<div class="notice warn" id="blocking-notice">
<div class="n-title" id="notice-title">…</div>
<div class="n-body" id="notice-body">…</div>
</div>
<span class="tab-count" id="count-match">0</span>
<nav class="side-nav"><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
</body></html>""",
wait_until="domcontentloaded",
)
page.add_script_tag(content=self.inject_js)
page.wait_for_function("() => typeof applyCompanyWorkspace === 'function'")
@staticmethod
def _colors(page) -> dict:
return page.evaluate(
"""() => {
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
const state = document.getElementById('workspaceConfirmState');
const pill = document.getElementById('workspacePendingStatus');
const notice = document.getElementById('blocking-notice');
const cs = (el) => getComputedStyle(el);
return {
stepClass: step.className,
stateText: state.textContent,
stateColor: cs(state).color,
pillClass: pill.className,
pillColor: cs(pill).color,
pillText: pill.textContent,
noticeClass: notice.className,
};
}"""
)
def test_pending_stays_warn_completed_turns_success(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 900})
self._open_fixture(page)
page.evaluate(
"""() => applyCompanyWorkspace({
pending_unilateral: 2,
pending_total: 2,
unilateral_events: [
{event_id: 1, amount: '100.00', currency: 'CNY',
counterparty_company_name: '', effective_at: '2026-07-01'}
]
})"""
)
pending = self._colors(page)
self.assertIn("doing", pending["stepClass"])
self.assertNotIn("done", pending["stepClass"].split())
self.assertIn("pill-warn", pending["pillClass"])
self.assertNotIn("pill-success", pending["pillClass"])
self.assertIn("warn", pending["noticeClass"].split())
self.assertIn("待处理", pending["stateText"])
self.assertIn("待处理", pending["pillText"])
page.evaluate(
"""() => applyCompanyWorkspace({
pending_unilateral: 0,
pending_total: 0,
unilateral_events: []
})"""
)
done = self._colors(page)
self.assertIn("done", done["stepClass"])
self.assertNotIn("doing", done["stepClass"].split())
self.assertIn("pill-success", done["pillClass"])
self.assertNotIn("pill-warn", done["pillClass"])
self.assertIn("success", done["noticeClass"].split())
self.assertEqual("已完成", done["stateText"])
self.assertEqual("已完成", done["pillText"])
self.assertNotEqual(pending["stateColor"], done["stateColor"])
self.assertNotEqual(pending["pillColor"], done["pillColor"])
browser.close()
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
"""HEL-177: 公司端转账往来页面(方案 A)结构与接口契约。"""
from __future__ import annotations
import re
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
class TransfersPageSourceContractTests(unittest.TestCase):
def test_company_html_has_nav_overview_and_layers(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
self.assertIn('data-view="transfers"', html)
self.assertIn("转账往来", html)
self.assertIn('id="workspaceTransfersCard"', html)
self.assertIn("转账往来概览", html)
self.assertIn('data-page="transfers"', html)
self.assertIn('id="transfersOverviewLayer"', html)
self.assertIn('id="transfersDetailLayer"', html)
self.assertIn('id="transferEvidenceDrawer"', html)
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=6", html)
self.assertIn("app.js?v=12", html)
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
flows = html.index('data-view="flows"')
transfers = html.index('data-view="transfers"')
reconcile = html.index('data-view="reconcile"')
self.assertLess(flows, transfers)
self.assertLess(transfers, reconcile)
def test_app_js_wires_real_apis_no_demo_totals(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn('transfers: "转账往来"', js)
self.assertIn("/api/company/intercompany/summary", js)
self.assertIn("/api/company/intercompany/events", js)
self.assertIn("/api/company/intercompany/export.csv", js)
self.assertIn("/api/company/transfer-events/", js)
self.assertIn("function loadTransfersSummary", js)
self.assertIn("function openTransfersDetail", js)
self.assertIn("function openTransferEvidence", js)
self.assertIn("function initTransfers", js)
self.assertIn("initTransfers()", js)
self.assertIn("期间净变动", js)
self.assertIn("has_opening", js)
# 不得把「期末余额」写死为无期初时的标签
self.assertNotRegex(js, r'netLabelForWindow[^{]+{[^}]*return "期末余额"')
def test_design_system_scopes_info_to_transfers_module(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn('a[data-view="transfers"].active', css)
self.assertIn("var(--info-soft)", css)
self.assertIn('[data-page="transfers"] .btn-primary', css)
self.assertIn(".xfer-split", css)
self.assertIn(".xfer-split-pane.confirmed", css)
self.assertIn(".xfer-split-pane.pending", css)
# 无新色硬编码
self.assertNotRegex(css, r"\.xfer-split[^\{]*\{[^}]*#[0-9a-fA-F]{3,8}")
class TransfersDetailObservationContractTests(unittest.TestCase):
def test_server_exposes_batch_fields_on_observations(self) -> None:
server = (ROOT / "server.py").read_text(encoding="utf-8")
self.assertIn("import_batch_id", server)
self.assertIn("original_filename", server)
self.assertIn("counterparty_account_masked", server)
def _chromium_available() -> bool:
try:
import ctypes.util
return bool(ctypes.util.find_library("atk-1.0"))
except Exception:
return False
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过布局冒烟")
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过布局冒烟")
class TransfersPageLayoutSmokeTests(unittest.TestCase):
"""静态壳:360 / 820 / 1440 无横向溢出。"""
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def test_transfers_shell_no_horizontal_overflow(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
for width in (360, 820, 1440):
page.set_viewport_size({"width": width, "height": 900})
page.set_content(
html.replace('src="app.js?v=12"', 'src=""'),
base_url=self.base,
)
page.evaluate(
"""() => {
document.querySelectorAll('.app-view').forEach((el) => {
el.classList.toggle('is-active', el.dataset.page === 'transfers');
});
const data = document.getElementById('transfersData');
const loading = document.getElementById('transfersLoading');
if (loading) loading.hidden = true;
if (data) data.hidden = false;
}"""
)
overflow = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
self.assertFalse(overflow, f"{width}px 出现横向溢出")
browser.close()
if __name__ == "__main__":
unittest.main()
+9 -5
View File
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
class MigrationTests(PersistenceTestCase):
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
first = applied_versions(self.connection)
self.assertEqual([1, 2, 3, 4, 5, 6, 7], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -85,19 +85,23 @@ class MigrationTests(PersistenceTestCase):
"system_settings",
"system_setting_changes",
"reminders",
"closed_periods",
"opening_balance_revisions",
"coverage_gaps",
"no_business_attestations",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -105,7 +109,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
self.assertEqual([8, 7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
+46 -26
View File
@@ -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">
+1082 -27
View File
File diff suppressed because it is too large Load Diff
+309 -15
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团公司业务端" />
<title>公司业务端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=5" />
<link rel="stylesheet" href="design-system.css?v=6" />
</head>
<body data-portal="company">
<a class="skip-link" href="#main-content">跳到主要内容</a>
@@ -22,6 +22,7 @@
<a data-view="upload" href="#upload"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 16V4m0 0l-4 4m4-4l4 4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg><span class="nav-label">流水导入</span></a>
<a data-view="manual" href="#manual"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg><span class="nav-label">手工记录</span></a>
<a data-view="flows" href="#flows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span class="nav-label">流水管理</span></a>
<a data-view="transfers" href="#transfers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M7 7h13v3l-4-2.5L20 5V8"/><path d="M17 17H4v-3l4 2.5L4 19v-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg><span class="nav-label">转账往来</span><span class="nav-badge" id="transfersNavBadge" hidden>0</span></a>
<a data-view="reconcile" href="#reconcile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">往来确认</span><span class="nav-badge">5</span></a>
<div class="nav-group">账户与消息</div>
<a data-view="accounts" href="#accounts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg><span class="nav-label">银行账户</span></a>
@@ -63,7 +64,7 @@
<div class="card">
<div class="card-head">
<span class="card-title">账期流程 · 2026-07<span class="sub">当前停在第 3 步「往来确认」,完成后即可等待集团结账</span></span>
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">当前停在第 3 步「往来确认」,完成后即可等待集团结账</span></span>
<span class="pill pill-warn">结账日顺延至 08-29</span>
</div>
<div class="flow">
@@ -97,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>
@@ -141,29 +153,29 @@
</div>
<div class="stack">
<div class="card">
<div class="card" id="workspaceTransfersCard">
<div class="card-head">
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与管理端口径一致 · 单位:万元</span></span>
<span class="card-title">转账往来概览<span class="sub" id="workspaceTransfersSub">加载中…</span></span>
</div>
<div class="mini-stats">
<div class="mini-stats" id="workspaceTransfersStats">
<div class="mini-stat">
<div class="ms-label">借方合计</div>
<div class="ms-value">18,420.50<span class="unit">万元</span></div>
<div class="ms-label">转入合计</div>
<div class="ms-value pos" id="wsTfIn"></div>
</div>
<div class="mini-stat">
<div class="ms-label">贷方合计</div>
<div class="ms-value">12,386.00<span class="unit">万元</span></div>
<div class="ms-label">转出合计</div>
<div class="ms-value neg" id="wsTfOut"></div>
</div>
<div class="mini-stat">
<div class="ms-label">明细笔数</div>
<div class="ms-value">128<span class="unit"></span></div>
<div class="ms-label" id="wsTfNetLabel">期间净变动</div>
<div class="ms-value" id="wsTfNet"></div>
</div>
<div class="mini-stat">
<div class="ms-label">期末净往来 · 应收方向</div>
<div class="ms-value pos">+6,034.50<span class="unit">万元</span></div>
<div class="ms-label">待确认 · 不计入合计</div>
<div class="ms-value" id="wsTfPending" style="color: var(--warn);"></div>
</div>
</div>
<button class="btn btn-ghost" data-view-link="flows" style="width: 100%; margin-top: 12px;">查看本公司逐笔流水</button>
<button class="btn btn-ghost" data-view-link="transfers" style="width: 100%; margin-top: 12px;">查看转账往来明细</button>
</div>
<div class="card">
@@ -356,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>
@@ -421,6 +443,224 @@
</div>
</section>
<section class="app-view" data-page="transfers">
<div id="transfersOverviewLayer">
<div class="page-head">
<div>
<h1>转账往来</h1>
<p class="page-sub" id="transfersPageSub">加载本公司与集团内其他公司的转账往来…</p>
</div>
<div class="page-actions">
<button class="btn" type="button" id="transfersExportBtn">导出台账</button>
<button class="btn btn-primary" type="button" id="transfersGoConfirm" data-view-link="reconcile" hidden>去确认待确认</button>
</div>
</div>
<div id="transfersLoading" class="card" hidden>
<div class="grid grid-4" style="margin-bottom: 14px;">
<div class="skeleton" style="height: 88px;"></div>
<div class="skeleton" style="height: 88px;"></div>
<div class="skeleton" style="height: 88px;"></div>
<div class="skeleton" style="height: 88px;"></div>
</div>
<div class="skeleton skeleton-line" style="width: 70%;"></div>
<div class="skeleton skeleton-line" style="width: 92%;"></div>
<div class="skeleton skeleton-line" style="width: 55%; margin-bottom: 0;"></div>
<div class="loading-inline" style="margin-top: 16px;">加载中,正在计算往来余额…</div>
</div>
<div id="transfersError" class="notice danger" hidden>
<div>
<div class="n-title">转账往来数据加载失败</div>
<div class="n-body" id="transfersErrorBody">服务器连接异常。为避免误读,本页不展示任何金额。</div>
</div>
<div class="row" style="margin-top: 12px; gap: 8px;">
<button class="btn btn-primary" type="button" id="transfersRetryBtn">重新加载</button>
</div>
</div>
<div id="transfersEmpty" class="card" hidden>
<div class="grid grid-4" id="transfersEmptyStats"></div>
<div class="empty" style="margin-top: 14px;">
<div class="e-title">该期间暂无转账往来</div>
<div>可能尚未导入流水,或集团内往来确认尚未完成。导入本公司银行流水后,系统会自动计算公司间往来。</div>
<div class="row" style="justify-content: center; margin-top: 14px; gap: 8px;">
<button class="btn btn-primary" type="button" data-view-link="upload">去导入流水</button>
<button class="btn" type="button" data-view-link="accounts">查看银行账户</button>
</div>
</div>
</div>
<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>
<div class="stat-foot" id="tfStatCompaniesFoot">统计窗口内有往来的对方公司</div>
</div>
<div class="card stat-card">
<div class="stat-label"><span class="stat-dot success"></span>本期转入 · 流入</div>
<div class="stat-value amt-in" id="tfStatIn"></div>
<div class="stat-foot">已确认转入合计 · 待确认不计入</div>
</div>
<div class="card stat-card">
<div class="stat-label"><span class="stat-dot danger"></span>本期转出 · 流出</div>
<div class="stat-value amt-out" id="tfStatOut"></div>
<div class="stat-foot">已确认转出合计 · 待确认不计入</div>
</div>
<div class="card stat-card">
<div class="stat-label"><span class="stat-dot info"></span><span id="tfStatNetTitle">期间净变动</span></div>
<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;">
<div class="xfer-split-pane confirmed">
<div class="xfer-split-head">
<span class="pill pill-success">已确认</span>
<span class="meta" id="tfConfirmedCount">0 笔</span>
</div>
<div class="xfer-split-value num" id="tfConfirmedNet"></div>
<div class="xfer-split-note">计入上方合计与期间净变动</div>
</div>
<div class="xfer-split-pane pending">
<div class="xfer-split-head">
<span class="pill pill-warn">待确认</span>
<span class="meta" id="tfPendingCount">0 笔</span>
</div>
<div class="xfer-split-value num" id="tfPendingAmount"></div>
<div class="xfer-split-note">单列展示,不计入已确认合计</div>
</div>
</div>
<div class="card" style="margin-top: 14px;">
<div class="card-head">
<span class="card-title">按对方公司查看<span class="sub">点击行进入该公司往来明细</span></span>
<span class="pill pill-info">仅本公司数据</span>
</div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table" id="transfersCpTable">
<thead>
<tr>
<th>对方公司</th>
<th class="num-col">已确认转入</th>
<th class="num-col">已确认转出</th>
<th class="num-col">净往来</th>
<th>方向</th>
<th>待确认</th>
<th>最近往来日</th>
</tr>
</thead>
<tbody id="transfersCpBody"></tbody>
<tfoot id="transfersCpFoot"></tfoot>
</table>
</div>
</div>
<div class="notice info" style="margin-top: 14px;">
<div class="n-title">口径说明</div>
<div class="n-body">仅含集团内公司间转账(HEL-169)。已确认与待确认严格分开;待确认不计入合计。起算日/期初未就绪时展示「期间净变动」,不得当作期末余额。银行原始流水只读,调整须走冲销/调整单留痕。</div>
</div>
</div>
</div>
<div id="transfersDetailLayer" hidden>
<div class="page-head">
<div>
<button class="btn btn-ghost" type="button" id="transfersBackBtn" style="padding-left: 0; margin-bottom: 4px;">← 返回总览</button>
<h1 id="transfersDetailTitle">往来明细</h1>
<p class="page-sub" id="transfersDetailSub">筛选后加载更多 · 单位:万元</p>
</div>
<div class="page-actions">
<button class="btn" type="button" id="transfersDetailExportBtn">导出台账</button>
</div>
</div>
<div class="grid grid-3" id="transfersDetailStats" style="margin-bottom: 14px;">
<div class="card stat-card">
<div class="stat-label">往来笔数</div>
<div class="stat-value" id="tfDetailCount"></div>
<div class="stat-foot" id="tfDetailCountFoot">当前筛选</div>
</div>
<div class="card stat-card">
<div class="stat-label">转入 · 流入</div>
<div class="stat-value amt-in" id="tfDetailIn"></div>
</div>
<div class="card stat-card">
<div class="stat-label">转出 · 流出</div>
<div class="stat-value amt-out" id="tfDetailOut"></div>
</div>
</div>
<form class="filters" id="transfersFilterForm" novalidate>
<div class="field">
<label for="tfFilterDirection">方向</label>
<select class="select" id="tfFilterDirection" name="direction">
<option value="">全部</option>
<option value="in">转入</option>
<option value="out">转出</option>
</select>
</div>
<div class="field">
<label for="tfFilterState">确认状态</label>
<select class="select" id="tfFilterState" name="state">
<option value="">全部</option>
<option value="confirmed">已确认</option>
<option value="pending">待确认</option>
</select>
</div>
<div class="field">
<label for="tfFilterFrom">日期起</label>
<input class="input num-input" id="tfFilterFrom" name="from" type="date" />
</div>
<div class="field">
<label for="tfFilterTo">日期止</label>
<input class="input num-input" id="tfFilterTo" name="to" type="date" />
</div>
<button class="btn" type="button" id="tfFilterReset">清空筛选</button>
<button class="btn btn-primary" type="submit">查询</button>
</form>
<div class="table-wrap">
<table class="ds-table" id="transfersEventTable">
<thead>
<tr>
<th>日期</th>
<th class="wrap">摘要</th>
<th>方向</th>
<th class="num-col">金额(万元)</th>
<th>状态</th>
<th></th>
</tr>
</thead>
<tbody id="transfersEventBody"></tbody>
</table>
<div class="empty" id="transfersEventEmpty" hidden>
<div class="e-title">当前筛选条件下没有流水</div>
<div>可清空筛选后重试,或返回总览查看其他对方公司。</div>
<div class="row" style="justify-content: center; margin-top: 12px;">
<button class="btn" type="button" id="tfEmptyClear">清空筛选</button>
</div>
</div>
<div class="table-foot">
<span id="transfersEventFoot">共 0 笔</span>
<button class="btn btn-sm" type="button" id="transfersLoadMore" hidden>加载更多</button>
</div>
</div>
</div>
</section>
<section class="app-view" data-page="reconcile">
<div class="page-head">
<div>
@@ -661,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">
@@ -813,7 +1084,30 @@
</form>
</div>
</div>
<aside class="drawer" id="transferEvidenceDrawer" aria-label="银行原始流水" aria-hidden="true">
<div class="drawer-head">
<div>
<span class="pill pill-info" id="tfEvTag">银行原始流水 · 只读</span>
<h2 class="d-title" id="tfEvTitle">原始流水</h2>
<p class="d-desc" id="tfEvDesc"></p>
</div>
<button type="button" class="icon-button" data-close-transfer-evidence 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="tfEvFields"></dl>
</div>
<div class="drawer-tip">
<strong>铁律提示</strong>
银行原始流水永远只读。金额或归属有误时,须由管理员通过冲销/调整单留痕处理,本页不得直接改数。
</div>
<div class="drawer-foot">
<button type="button" class="btn" data-close-transfer-evidence>关闭</button>
</div>
</aside>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=10"></script>
<script src="app.js?v=12"></script>
</body>
</html>
+95
View File
@@ -988,3 +988,98 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans
/* ─── 提示条补充:成功态 ───────────────────────────────────────── */
.notice.success { background: var(--success-soft); border-color: color-mix(in oklch, var(--success) 30%, transparent); }
.notice.success .n-title { color: color-mix(in oklch, var(--success) 82%, black); }
/* ─── 公司端 · 转账往来(方案 A,HEL-177)───────────────────────────
新模块范围内用 --info 品蓝;不改全站 accent。取值一律既有 token。 */
body[data-portal="company"] .side-nav a[data-view="transfers"].active {
background: var(--info-soft);
color: var(--fg);
}
body[data-portal="company"] .side-nav a[data-view="transfers"].active svg {
color: var(--info);
}
[data-page="transfers"] .btn-primary {
background: var(--info);
border-color: var(--info);
color: var(--surface);
}
[data-page="transfers"] .btn-primary:hover {
background: color-mix(in oklch, var(--info) 88%, black);
border-color: color-mix(in oklch, var(--info) 88%, black);
}
[data-page="transfers"] .loading-inline::before {
border-top-color: var(--info);
}
[data-page="transfers"] .stat-card .stat-value.amt-in { color: var(--success); }
[data-page="transfers"] .stat-card .stat-value.amt-out { color: var(--danger); }
[data-page="transfers"] .ds-table tbody tr.is-pending {
background: color-mix(in oklch, var(--warn) 8%, transparent);
}
[data-page="transfers"] .ds-table tbody tr.is-pending:hover {
background: color-mix(in oklch, var(--warn) 14%, transparent);
}
.xfer-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface);
}
.xfer-split-pane {
padding: 16px 18px;
}
.xfer-split-pane.confirmed {
background: color-mix(in oklch, var(--success) 6%, var(--surface));
border-right: 1px solid var(--border);
}
.xfer-split-pane.pending {
background: color-mix(in oklch, var(--warn) 8%, var(--surface));
}
.xfer-split-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.xfer-split-value {
font-size: 22px;
font-weight: 650;
letter-spacing: -0.02em;
}
.xfer-split-pane.confirmed .xfer-split-value { color: var(--success); }
.xfer-split-pane.pending .xfer-split-value { color: color-mix(in oklch, var(--warn) 80%, black); }
.xfer-split-note {
margin-top: 6px;
font-size: 12px;
color: var(--muted);
}
.xfer-dir-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
}
.xfer-dir-tag.recv {
background: var(--success-soft);
color: var(--success);
}
.xfer-dir-tag.pay {
background: var(--danger-soft);
color: var(--danger);
}
.xfer-dir-tag.flat {
background: var(--fg-soft);
color: var(--muted);
}
@media (max-width: 820px) {
.xfer-split { grid-template-columns: 1fr; }
.xfer-split-pane.confirmed { border-right: 0; border-bottom: 1px solid var(--border); }
}