Compare commits

...
Author SHA1 Message Date
总工andmultica-agent d6f39e018d HEL-208: 整合 b49892e 的 formatWan 拆分修复到 2fb1f2b(丢弃其对 HEL-206 方向修复与测试的误回退)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 11:49:23 +00:00
2fb1f2b462 HEL-206: 修正公司间期末余额方向为转出=应收增加
翻转 _pair_net_change 符号,与往来层/页面口径一致;同步修正旧断言并新增期初200+转出100=期末300的双边守恒与浏览器方向冒烟。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:49:56 +00:00
fe6b4e59ec HEL-203: 修复原因弹窗作用域、首屏断档与表单 reset
将 openModal/closeModal 提升为顶层唯一定义,公司端启动即加载断档提醒,
并在 await 前提取表单引用;补充 Playwright 两端冒烟与源码契约测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:29:07 +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
40f7a91a0f HEL-176: 公司端转账往来明细筛选与导出
新增 events 组合筛选 + keyset 分页,以及仅导出已确认明细的 CSV(含审计);
与 B-44 ledger /events 按查询参数分发,不改库表与确认语义。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 13:14:02 +00:00
cad12b3d28 HEL-175: 公司端转账往来汇总接口
新增 GET /api/company/intercompany/summary:会话 company_id 强制隔离,
已确认走 eligible_intercompany_events,待确认单列,Decimal 字符串金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 12:58:52 +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
20 changed files with 7225 additions and 79 deletions
+1
View File
@@ -1,6 +1,7 @@
__pycache__/
*.py[cod]
.venv/
.chrome-libs/
.tmp-*/
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
+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()
+581 -4
View File
@@ -11,8 +11,9 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import (
auth, 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
@@ -155,6 +180,14 @@ class AppHandler(SimpleHTTPRequestHandler):
self._handle_admin_intercompany_evidence(int(admin_evidence.group(1)))
return
# Company transfer-summary (HEL-169/HEL-175/HEL-176)
if path == "/api/company/intercompany/summary":
self._handle_company_intercompany_summary(query)
return
if path == "/api/company/intercompany/export.csv":
self._handle_company_intercompany_export_csv(query)
return
# B-44 intercompany positions (company, own-company scope)
if path == "/api/company/intercompany/balances":
self._handle_company_intercompany_balances(query)
@@ -164,7 +197,12 @@ class AppHandler(SimpleHTTPRequestHandler):
self._handle_company_intercompany_pair(int(company_pair.group(1)), query)
return
if path == "/api/company/intercompany/events":
self._handle_company_intercompany_events(query)
# HEL-176 transfer-detail list shares this path with B-44 ledger
# events; dispatch by distinctive query params (to/direction/…).
if company_transfers.is_transfer_summary_events_query(query):
self._handle_company_transfer_summary_events(query)
else:
self._handle_company_intercompany_events(query)
return
if path == "/api/company/manual-records":
self._handle_company_manual_records(query)
@@ -254,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
@@ -288,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
# ------------------------------------------------------------------
@@ -783,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()
@@ -1439,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:
@@ -2426,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"],),
@@ -2446,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"],
@@ -2858,6 +3304,137 @@ class AppHandler(SimpleHTTPRequestHandler):
return None, None
return user, user["company_id"]
def _handle_company_intercompany_summary(self, query: dict[str, list[str]]) -> None:
"""Own-company transfer summary; company_id is session-only (HEL-175)."""
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
# Front-end must never supply company_id; reject even matching values.
if (query.get("company_id") or [None])[0] is not None:
self._send_json(
400,
{"status": "error", "message": "不允许传入 company_id 参数。"},
)
return
as_of = (query.get("as_of") or [None])[0]
try:
payload = company_transfers.company_intercompany_summary(
connection, company_id=int(company_id), as_of=as_of
)
except company_transfers.TransferSummaryInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _reject_forged_company_id(self, query: dict[str, list[str]]) -> bool:
"""Return True when the handler already sent a 400 for company_id."""
if (query.get("company_id") or [None])[0] is not None:
self._send_json(
400,
{"status": "error", "message": "不允许传入 company_id 参数。"},
)
return True
return False
def _handle_company_transfer_summary_events(
self, query: dict[str, list[str]]
) -> None:
"""HEL-176 filtered transfer-detail list (keyset pagination)."""
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
if self._reject_forged_company_id(query):
return
try:
payload = company_transfers.company_intercompany_events(
connection,
company_id=int(company_id),
from_=(query.get("from") or [None])[0],
to=(query.get("to") or [None])[0],
counterparty_id=(query.get("counterparty_id") or [None])[0],
direction=(query.get("direction") or [None])[0],
state=(query.get("state") or [None])[0],
limit=(query.get("limit") or [None])[0],
cursor=(query.get("cursor") or [None])[0],
)
except company_transfers.TransferSummaryInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_company_intercompany_export_csv(
self, query: dict[str, list[str]]
) -> None:
"""Confirmed-only CSV export; session company scope + audit (HEL-176)."""
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
if self._reject_forged_company_id(query):
return
# Pending must never leave via export, even if a client sends state=.
if (query.get("state") or [None])[0] not in (None, "", "confirmed"):
self._send_json(
400,
{
"status": "error",
"message": "导出仅支持已确认明细,不允许导出待确认。",
},
)
return
try:
items, meta = company_transfers.company_intercompany_export_rows(
connection,
company_id=int(company_id),
from_=(query.get("from") or [None])[0],
to=(query.get("to") or [None])[0],
counterparty_id=(query.get("counterparty_id") or [None])[0],
direction=(query.get("direction") or [None])[0],
)
except company_transfers.TransferSummaryInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
content = company_transfers.render_intercompany_export_csv(items)
detail_parts = [
f"rows:{meta['row_count']}",
f"from:{meta['start']}",
f"to:{meta['end']}",
"state:confirmed",
]
if meta["counterparty_id"] is not None:
detail_parts.append(f"counterparty_id:{meta['counterparty_id']}")
if meta["direction"] is not None:
detail_parts.append(f"direction:{meta['direction']}")
auth.audit(
connection,
"export_intercompany_csv",
actor=user,
target=f"company:{company_id}",
detail=";".join(detail_parts),
ip=self._client_ip,
)
self.send_response(200)
self.send_header("Content-Type", "text/csv; charset=utf-8")
self.send_header(
"Content-Disposition",
'attachment; filename="intercompany-export.csv"',
)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(content)
finally:
connection.close()
def _handle_company_intercompany_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
+960
View File
@@ -0,0 +1,960 @@
"""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()
# 站在本公司视角:正数=应收方向。转出/垫付(payer)使应收增加。
# 与 company_transfers.net = outflow inflow 及页面文案口径一致。
total = Decimal("0")
for row in rows:
amount = _parse_decimal(row["amount"])
if row["payer_id"] == viewer_id:
total += amount
elif row["payee_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}
+754
View File
@@ -0,0 +1,754 @@
"""Company-portal intercompany transfer summary / detail / export (HEL-175/176).
Confirmed totals reuse the authoritative ``eligible_intercompany_events``
view (intercompany + paired or locked). Pending counts/amounts are listed
separately and never enter outflow, inflow or net. All money math uses
``Decimal`` on stored TEXT amounts — never float or SQLite SUM.
Detail listing (HEL-176) applies counterparty / date / direction / state
filters inside SQL before LIMIT, and uses keyset pagination on
(effective_at, event_id) descending. Export only ships confirmed rows.
"""
from __future__ import annotations
import base64
import csv
import io
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
import re
import sqlite3
from . import calculation, matching, settings
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_ZERO = Decimal("0.00")
_DEFAULT_PAGE = 50
_MAX_PAGE = 200
_MAX_EXPORT_ROWS = 20000
class TransferSummaryInputError(ValueError):
"""Invalid query parameters (mapped to HTTP 400)."""
def today_shanghai() -> str:
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
def _validate_date(value: object, label: str) -> str:
text = str(value or "").strip()
if not text or not _DATE_RE.fullmatch(text):
raise TransferSummaryInputError(f"{label}必须是 YYYY-MM-DD 格式。")
try:
datetime.strptime(text, "%Y-%m-%d")
except ValueError as exc:
raise TransferSummaryInputError(f"{label}不是有效日期。") from exc
return text
def _money(value: Decimal) -> str:
return format(value.quantize(Decimal("0.01")), "f")
def _as_decimal(raw: object) -> Decimal:
try:
return Decimal(str(raw))
except (InvalidOperation, TypeError) as exc:
raise TransferSummaryInputError("金额数据无效,无法汇总。") from exc
def _company_row(connection: sqlite3.Connection, company_id: int) -> dict[str, object]:
row = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if row is None:
raise TransferSummaryInputError("本公司不存在。")
return {"id": int(row["id"]), "name": row["name"]}
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()
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.
start = end
return start, end
def _load_confirmed(
connection: sqlite3.Connection, company_id: int, start: str, end: str
) -> list[sqlite3.Row]:
return connection.execute(
"""
SELECT e.event_id, e.decision_id, e.effective_at, e.amount, e.currency,
e.payer_company_id, e.payee_company_id, e.pairing,
cpayer.name AS payer_company_name,
cpayee.name AS payee_company_name
FROM eligible_intercompany_events e
JOIN companies cpayer ON cpayer.id = e.payer_company_id
JOIN companies cpayee ON cpayee.id = e.payee_company_id
WHERE (e.payer_company_id = ? OR e.payee_company_id = ?)
AND e.effective_at >= ?
AND e.effective_at <= ?
ORDER BY e.event_id
""",
(company_id, company_id, start, end + "T23:59:59"),
).fetchall()
def _load_pending(
connection: sqlite3.Connection, company_id: int, start: str, end: str
) -> list[sqlite3.Row]:
"""Pending = not eligible confirmed, still company-visible for tip only.
Includes unresolved / needs_review / internal_single, plus any
intercompany decision that is neither paired nor locked.
"""
return connection.execute(
"""
SELECT c.event_id, d.id AS decision_id, d.effective_at, d.amount,
d.currency, d.classification, d.pairing, d.locked,
payer.company_id AS payer_company_id,
payee.company_id AS payee_company_id,
cpayer.name AS payer_company_name,
cpayee.name AS payee_company_name
FROM current_transfer_decisions c
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN canonical_transfer_events e ON e.id = c.event_id
LEFT JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
LEFT JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
WHERE e.lifecycle = 'active'
AND (payer.company_id = ? OR payee.company_id = ?)
AND d.effective_at >= ?
AND d.effective_at <= ?
AND (
d.classification IN ('unresolved', 'needs_review', 'internal_single')
OR (
d.classification = 'intercompany'
AND d.pairing != 'paired'
AND d.locked = 0
)
)
ORDER BY c.event_id
""",
(company_id, company_id, start, end + "T23:59:59"),
).fetchall()
def _counterparty_of(row: sqlite3.Row, company_id: int) -> tuple[int | None, str | None]:
payer = row["payer_company_id"]
payee = row["payee_company_id"]
if payer is not None and int(payer) == int(company_id):
if payee is None:
return None, None
return int(payee), row["payee_company_name"]
if payee is not None and int(payee) == int(company_id):
if payer is None:
return None, None
return int(payer), row["payer_company_name"]
return None, None
def _is_outflow(row: sqlite3.Row, company_id: int) -> bool:
return row["payer_company_id"] is not None and int(row["payer_company_id"]) == int(
company_id
)
def company_intercompany_summary(
connection: sqlite3.Connection,
*,
company_id: int,
as_of: str | None = None,
) -> dict[str, object]:
"""Build the HEL-169 summary payload for one company session."""
own = _company_row(connection, company_id)
start, end = _window_bounds(connection, as_of)
confirmed_rows = _load_confirmed(connection, company_id, start, end)
pending_rows = _load_pending(connection, company_id, start, end)
seen_confirmed: set[int] = set()
outflow = _ZERO
inflow = _ZERO
outflow_count = 0
inflow_count = 0
# counterparty_id -> bucket
buckets: dict[int, dict[str, object]] = {}
def bucket_for(cp_id: int, cp_name: str | None) -> dict[str, object]:
bucket = buckets.get(cp_id)
if bucket is None:
bucket = {
"company_id": cp_id,
"company_name": cp_name or "",
"confirmed_outflow": _ZERO,
"confirmed_inflow": _ZERO,
"pending_count": 0,
"last_effective_at": None,
}
buckets[cp_id] = bucket
elif cp_name and not bucket["company_name"]:
bucket["company_name"] = cp_name
return bucket
def touch_last(bucket: dict[str, object], effective_at: str | None) -> None:
if not effective_at:
return
previous = bucket["last_effective_at"]
if previous is None or str(effective_at) > str(previous):
bucket["last_effective_at"] = effective_at
for row in confirmed_rows:
event_id = int(row["event_id"])
if event_id in seen_confirmed:
continue
seen_confirmed.add(event_id)
amount = _as_decimal(row["amount"])
cp_id, cp_name = _counterparty_of(row, company_id)
if _is_outflow(row, company_id):
outflow += amount
outflow_count += 1
if cp_id is not None:
bucket = bucket_for(cp_id, cp_name)
bucket["confirmed_outflow"] += amount
touch_last(bucket, row["effective_at"])
else:
inflow += amount
inflow_count += 1
if cp_id is not None:
bucket = bucket_for(cp_id, cp_name)
bucket["confirmed_inflow"] += amount
touch_last(bucket, row["effective_at"])
pending_total = _ZERO
seen_pending: set[int] = set()
for row in pending_rows:
event_id = int(row["event_id"])
if event_id in seen_pending or event_id in seen_confirmed:
continue
seen_pending.add(event_id)
amount = _as_decimal(row["amount"])
pending_total += amount
cp_id, cp_name = _counterparty_of(row, company_id)
if cp_id is not None:
bucket = bucket_for(cp_id, cp_name)
bucket["pending_count"] = int(bucket["pending_count"]) + 1
touch_last(bucket, row["effective_at"])
net = outflow - inflow
if net > 0:
net_direction: str | None = "receivable"
elif net < 0:
net_direction = "payable"
else:
net_direction = None
counterparties = []
for cp_id in sorted(
buckets.keys(),
key=lambda i: (
-(
buckets[i]["confirmed_outflow"] # type: ignore[operator]
+ buckets[i]["confirmed_inflow"]
),
buckets[i]["company_name"] or "",
i,
),
):
bucket = buckets[cp_id]
conf_out = bucket["confirmed_outflow"]
conf_in = bucket["confirmed_inflow"]
assert isinstance(conf_out, Decimal) and isinstance(conf_in, Decimal)
counterparties.append(
{
"company_id": cp_id,
"company_name": bucket["company_name"],
"confirmed_outflow": _money(conf_out),
"confirmed_inflow": _money(conf_in),
"net": _money(conf_out - conf_in),
"pending_count": int(bucket["pending_count"]),
"last_effective_at": bucket["last_effective_at"],
}
)
# 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": 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),
"outflow_count": outflow_count,
"inflow_total": _money(inflow),
"inflow_count": inflow_count,
"net_change": _money(net),
"net_direction": net_direction,
},
"pending": {
"count": len(seen_pending),
"amount_total": _money(pending_total),
},
"counterparties": counterparties,
}
# ---------------------------------------------------------------------------
# Detail list + CSV export (HEL-176)
# ---------------------------------------------------------------------------
def encode_cursor(effective_at: str, event_id: int) -> str:
raw = f"{effective_at}|{event_id}"
return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii")
def decode_cursor(cursor: str | None) -> tuple[str, int] | None:
if not cursor:
return None
try:
raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")
effective_at, event_id_text = raw.split("|", 1)
return effective_at, int(event_id_text)
except Exception as exc:
raise TransferSummaryInputError("分页游标无效。") from exc
def _parse_limit(raw: object | None, default: int = _DEFAULT_PAGE) -> int:
if raw is None or str(raw).strip() == "":
return default
try:
value = int(str(raw).strip())
except (TypeError, ValueError) as exc:
raise TransferSummaryInputError("limit 必须是正整数。") from exc
if value < 1:
raise TransferSummaryInputError("limit 必须是正整数。")
return min(value, _MAX_PAGE)
def _parse_optional_date(raw: object | None, label: str) -> str | None:
if raw is None or str(raw).strip() == "":
return None
return _validate_date(raw, label)
def _parse_direction(raw: object | None) -> str | None:
if raw is None or str(raw).strip() == "":
return None
value = str(raw).strip().lower()
if value not in ("out", "in"):
raise TransferSummaryInputError("direction 只能是 out 或 in。")
return value
def _parse_state(raw: object | None) -> str | None:
if raw is None or str(raw).strip() == "":
return None
value = str(raw).strip().lower()
if value not in ("confirmed", "pending"):
raise TransferSummaryInputError("state 只能是 confirmed 或 pending。")
return value
def _parse_counterparty_id(raw: object | None) -> int | None:
if raw is None or str(raw).strip() == "":
return None
try:
value = int(str(raw).strip())
except (TypeError, ValueError) as exc:
raise TransferSummaryInputError("counterparty_id 参数无效。") from exc
if value < 1:
raise TransferSummaryInputError("counterparty_id 参数无效。")
return value
def _event_window(
connection: sqlite3.Connection,
*,
from_: str | None,
to: str | None,
) -> tuple[str, str]:
end = _parse_optional_date(to, "to") or today_shanghai()
start_default = settings.get_settings(connection).get("start_date") or "2026-01-01"
start = _parse_optional_date(from_, "from") or _validate_date(
start_default, "start_date"
)
if start > end:
raise TransferSummaryInputError("from 不能晚于 to。")
return start, end
# Confirmed = eligible_intercompany_events. Pending matches summary tip set.
_CONFIRMED_PREDICATE = """
d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1)
"""
_PENDING_PREDICATE = """
(
d.classification IN ('unresolved', 'needs_review', 'internal_single')
OR (
d.classification = 'intercompany'
AND d.pairing != 'paired'
AND d.locked = 0
)
)
"""
_BOTH_PREDICATE = f"""
(
({_CONFIRMED_PREDICATE})
OR ({_PENDING_PREDICATE})
)
"""
def _state_predicate(state: str | None) -> str:
if state == "confirmed":
return f"({_CONFIRMED_PREDICATE})"
if state == "pending":
return f"({_PENDING_PREDICATE})"
return _BOTH_PREDICATE
def _list_select_sql() -> str:
return """
SELECT c.event_id, d.id AS decision_id, d.revision, d.classification,
d.pairing, d.amount, d.currency, d.effective_at, d.mode,
d.locked, d.rule_version, d.created_at, d.reason,
payer.company_id AS payer_company_id,
payee.company_id AS payee_company_id,
payer.bank_account_id AS payer_account_id,
payee.bank_account_id AS payee_account_id,
cpayer.name AS payer_company_name,
cpayee.name AS payee_company_name,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count,
(SELECT r.summary
FROM transfer_decision_observations o
JOIN source_rows r ON r.id = o.source_row_id
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN import_batches b ON b.id = s.import_batch_id
WHERE o.decision_id = d.id AND b.company_id = ?
ORDER BY o.id
LIMIT 1) AS summary
FROM current_transfer_decisions c
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN canonical_transfer_events e ON e.id = c.event_id
LEFT JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
LEFT JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
"""
def _build_event_filters(
*,
company_id: int,
start: str,
end: str,
counterparty_id: int | None,
direction: str | None,
state: str | None,
cursor: tuple[str, int] | None,
) -> tuple[str, list[object]]:
conditions = [
"e.lifecycle = 'active'",
"(payer.company_id = ? OR payee.company_id = ?)",
"d.effective_at >= ?",
"d.effective_at <= ?",
_state_predicate(state),
]
params: list[object] = [
company_id,
company_id,
start,
end + "T23:59:59",
]
if counterparty_id is not None:
# Counterparty is the other participant; own company stays forced above.
conditions.append(
"""
(
(payer.company_id = ? AND payee.company_id = ?)
OR (payee.company_id = ? AND payer.company_id = ?)
)
"""
)
params.extend([company_id, counterparty_id, company_id, counterparty_id])
if direction == "out":
conditions.append("payer.company_id = ?")
params.append(company_id)
elif direction == "in":
conditions.append("payee.company_id = ?")
params.append(company_id)
if cursor is not None:
cursor_at, cursor_id = cursor
conditions.append(
"""
(
d.effective_at < ?
OR (d.effective_at = ? AND c.event_id < ?)
)
"""
)
params.extend([cursor_at, cursor_at, cursor_id])
where = " WHERE " + " AND ".join(conditions)
return where, params
def _row_state(row: sqlite3.Row) -> str:
classification = row["classification"]
pairing = row["pairing"]
locked = bool(row["locked"])
if classification == "intercompany" and (pairing == "paired" or locked):
return "confirmed"
return "pending"
def _row_direction(row: sqlite3.Row, company_id: int) -> str | None:
if row["payer_company_id"] is not None and int(row["payer_company_id"]) == int(
company_id
):
return "out"
if row["payee_company_id"] is not None and int(row["payee_company_id"]) == int(
company_id
):
return "in"
return None
def _event_list_item(row: sqlite3.Row, company_id: int) -> dict[str, object]:
direction = _row_direction(row, company_id)
state = _row_state(row)
if direction == "out":
counterparty_company_id = row["payee_company_id"]
counterparty_company_name = row["payee_company_name"]
else:
counterparty_company_id = row["payer_company_id"]
counterparty_company_name = row["payer_company_name"]
summary = row["summary"] or row["reason"] or ""
return {
"event_id": int(row["event_id"]),
"decision_id": int(row["decision_id"]),
"revision": row["revision"],
"classification": row["classification"],
"pairing": row["pairing"],
"status": matching.exposed_status(row),
"state": state,
"direction": direction,
"amount": row["amount"],
"currency": row["currency"],
"effective_at": row["effective_at"],
"mode": row["mode"],
"locked": bool(row["locked"]),
"rule_version": row["rule_version"],
"summary": summary,
"own_company_id": company_id,
"counterparty_company_id": (
int(counterparty_company_id) if counterparty_company_id is not None else None
),
"counterparty_company_name": counterparty_company_name,
"evidence_count": row["evidence_count"],
}
def company_intercompany_events(
connection: sqlite3.Connection,
*,
company_id: int,
from_: str | None = None,
to: str | None = None,
counterparty_id: int | None | object = None,
direction: str | None | object = None,
state: str | None | object = None,
limit: object | None = None,
cursor: str | None = None,
) -> dict[str, object]:
"""Filtered keyset page of company-visible transfer events (HEL-176)."""
start, end = _event_window(connection, from_=from_, to=to)
cp_id = _parse_counterparty_id(counterparty_id)
direction_value = _parse_direction(direction)
state_value = _parse_state(state)
page_size = _parse_limit(limit)
cursor_tuple = decode_cursor(cursor)
where, params = _build_event_filters(
company_id=company_id,
start=start,
end=end,
counterparty_id=cp_id,
direction=direction_value,
state=state_value,
cursor=cursor_tuple,
)
# summary subquery binds own company_id first.
sql = (
_list_select_sql()
+ where
+ " ORDER BY d.effective_at DESC, c.event_id DESC LIMIT ?"
)
rows = connection.execute(
sql, (company_id, *params, page_size + 1)
).fetchall()
has_more = len(rows) > page_size
page = rows[:page_size]
items = [_event_list_item(row, company_id) for row in page]
next_cursor = None
if has_more and page:
last = page[-1]
next_cursor = encode_cursor(str(last["effective_at"]), int(last["event_id"]))
return {
"window": {"start": start, "end": end},
"events": items,
"next_cursor": next_cursor,
"has_more": has_more,
}
def company_intercompany_export_rows(
connection: sqlite3.Connection,
*,
company_id: int,
from_: str | None = None,
to: str | None = None,
counterparty_id: int | None | object = None,
direction: str | None | object = None,
) -> tuple[list[dict[str, object]], dict[str, object]]:
"""Confirmed-only rows for CSV export; pending never included."""
start, end = _event_window(connection, from_=from_, to=to)
cp_id = _parse_counterparty_id(counterparty_id)
direction_value = _parse_direction(direction)
where, params = _build_event_filters(
company_id=company_id,
start=start,
end=end,
counterparty_id=cp_id,
direction=direction_value,
state="confirmed",
cursor=None,
)
sql = (
_list_select_sql()
+ where
+ " ORDER BY d.effective_at DESC, c.event_id DESC LIMIT ?"
)
rows = connection.execute(
sql, (company_id, *params, _MAX_EXPORT_ROWS + 1)
).fetchall()
if len(rows) > _MAX_EXPORT_ROWS:
raise TransferSummaryInputError(
f"导出行数超过上限 {_MAX_EXPORT_ROWS},请缩小筛选范围。"
)
items = [_event_list_item(row, company_id) for row in rows]
meta = {
"start": start,
"end": end,
"counterparty_id": cp_id,
"direction": direction_value,
"state": "confirmed",
"row_count": len(items),
}
return items, meta
def render_intercompany_export_csv(items: list[dict[str, object]]) -> bytes:
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"日期",
"方向",
"对方公司",
"金额",
"币种",
"摘要",
"状态",
"配对",
"事件ID",
"决策ID",
]
)
direction_label = {"out": "转出", "in": "转入"}
for item in items:
writer.writerow(
[
item.get("effective_at") or "",
direction_label.get(str(item.get("direction") or ""), ""),
item.get("counterparty_company_name") or "",
item.get("amount") or "",
item.get("currency") or "",
item.get("summary") or "",
"已确认",
item.get("pairing") or "",
item.get("event_id") or "",
item.get("decision_id") or "",
]
)
# UTF-8 BOM so Excel opens the CSV with the right encoding.
return (chr(0xFEFF) + buffer.getvalue()).encode("utf-8")
def is_transfer_summary_events_query(query: dict[str, list[str]]) -> bool:
"""Discriminate HEL-176 transfer list from B-44 ledger ``/events``.
B-44 uses ``cutoff`` / subject / posting_kind / source_kind / pending_subject.
HEL-176 uses ``to`` / direction / counterparty_id / state=pending|confirmed
(without ledger-only knobs).
"""
if (query.get("direction") or [None])[0] is not None:
return True
if (query.get("counterparty_id") or [None])[0] is not None:
return True
if (query.get("to") or [None])[0] is not None:
return True
state = (query.get("state") or [None])[0]
if state in ("pending", "confirmed") and (query.get("cutoff") or [None])[0] is None:
# Bare state=confirmed without cutoff is the transfer-summary list;
# B-44 confirmed always pairs with cutoff in existing callers/tests.
if (query.get("subject") or [None])[0] is not None:
return False
if (query.get("posting_kind") or [None])[0] is not None:
return False
if (query.get("source_kind") or [None])[0] is not None:
return False
return True
return False
+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)
+593
View File
@@ -0,0 +1,593 @@
"""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"
)
# 甲转出 80 → 应收方向为正
self.assertEqual("80.00", balance["net_change"])
self.assertEqual("80.00", balance["closing"])
def test_opening_plus_outflow_increases_receivable(self) -> None:
"""期初应收 200 + 本期转出垫付 100 → 期末应收 300;乙方对称为应付 300。"""
from test_matching import MatchingBase
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
)
helper = object.__new__(MatchingBase)
helper.connection = self.connection
row_a = helper.add_row(
self.company_a,
own_account="6222000000000001",
cp_account="6222000000000002",
expense="100.00",
at="2026-01-15T10:00:00",
)
matching.reconcile_rows(self.connection, [row_a])
row_b = helper.add_row(
self.company_b,
own_account="6222000000000002",
cp_account="6222000000000001",
income="100.00",
at="2026-01-15T11:00:00",
)
matching.reconcile_rows(self.connection, [row_b])
bal_a = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("full", bal_a["basis"])
self.assertEqual("200", bal_a["opening"])
self.assertEqual("100.00", bal_a["net_change"])
self.assertEqual("300.00", bal_a["closing"])
bal_b = calculation.compute_pair_balance(
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
)
self.assertEqual("full", bal_b["basis"])
self.assertEqual("-200", bal_b["opening"])
self.assertEqual("-100.00", bal_b["net_change"])
self.assertEqual("-300.00", bal_b["closing"])
# 双边守恒
self.assertEqual(
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
Decimal("0"),
)
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=13", 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()
+500
View File
@@ -0,0 +1,500 @@
"""HTTP tests for company intercompany events list + CSV export (HEL-176).
Covers combined filters, keyset pagination, empty state, lateral access,
forged company_id, ID guessing, export isolation and audit logging.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]
class CompanyIntercompanyEventsTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self._old_db_path = server.DB_PATH
self._old_storage = server.STORAGE_DIR
server.DB_PATH = self.db_path
server.STORAGE_DIR = self.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(self.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
self.port = self.httpd.server_address[1]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.admin = Client("127.0.0.1", self.port)
status, _, data = self.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = self.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
self.initial_passwords: dict[str, str] = {}
self.company_a = self._create_company("甲公司", "cashier-a")
self.company_b = self._create_company("乙公司", "cashier-b")
self.company_c = self._create_company("丙公司", "cashier-c")
self.cashier_a = self._login_company("cashier-a")
self.cashier_b = self._login_company("cashier-b")
self.cashier_c = self._login_company("cashier-c")
self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a)
self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b)
self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c)
def tearDown(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()
server.DB_PATH = self._old_db_path
server.STORAGE_DIR = self._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
def _create_company(self, name: str, username: str) -> int:
status, _, data = self.admin.post_json(
"/api/admin/companies", {"name": name, "username": username}
)
assert status == 200, data
self.initial_passwords[username] = as_json(data)["initial_password"]
return as_json(data)["company_id"]
def _login_company(self, username: str) -> Client:
client = Client("127.0.0.1", self.port)
initial = self.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
def _approve_account(self, company_id: int, number: str, client: Client) -> int:
status, _, data = client.post_json(
"/api/company/accounts",
{
"bank_name": "中信银行",
"account_type": "基本户",
"account_number": number,
"start_date": "2026-01-01",
},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{
"decision": "approve",
"reason": "测试启用",
"effective_from": "2026-01-01",
},
)
assert status == 200, data
return account_id
def _upload_and_confirm(self, client: Client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
def _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict:
self._upload_and_confirm(
self.cashier_a,
self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
)
status, _, data = self.admin.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
single = next(
e
for e in as_json(data)["events"]
if e["status"] == "internal_single" and e["amount"] == amount
)
status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}")
self.assertEqual(200, status, data)
revision = as_json(data)["event"]["revision"]
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{
"action": "assign_participant",
"reason": "函证确认",
"expected_revision": revision,
"request_key": f"lock-{amount}-{at}",
"participant": {"role": "payee", "company_id": self.company_b},
},
)
self.assertEqual(200, status, data)
return as_json(data)["decision"]
def _seed_mixed(self) -> None:
"""Paired A→B 100, B→A 40, locked A→B 25, pending A→B 7, B→C 200."""
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")],
)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")],
)
self._lock_single("25.00", "2026-02-01 10:00:00")
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")],
)
self._upload_and_confirm(
self.cashier_c, self.company_c,
[incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")],
)
def _events(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"):
status, _, data = client.get(f"/api/company/intercompany/events?{query}")
return status, as_json(data) if data else {}
def _export(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"):
return client.get(f"/api/company/intercompany/export.csv?{query}")
# ------------------------------------------------------------------
# Auth / parameter guards
# ------------------------------------------------------------------
def test_requires_company_role(self) -> None:
status, payload = self._events(self.admin)
self.assertEqual(403, status, payload)
def test_rejects_forged_company_id_on_events_and_export(self) -> None:
status, payload = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&company_id={self.company_b}",
)
self.assertEqual(400, status, payload)
status, _, data = self._export(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&company_id={self.company_a}",
)
self.assertEqual(400, status, data)
def test_rejects_bad_filters(self) -> None:
status, payload = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=sideways"
)
self.assertEqual(400, status, payload)
status, payload = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=maybe"
)
self.assertEqual(400, status, payload)
status, payload = self._events(
self.cashier_a, "from=2026-13-40&to=2026-12-31"
)
self.assertEqual(400, status, payload)
def test_empty_window(self) -> None:
status, payload = self._events(self.cashier_a)
self.assertEqual(200, status, payload)
self.assertEqual([], payload["events"])
self.assertFalse(payload["has_more"])
self.assertIsNone(payload["next_cursor"])
# ------------------------------------------------------------------
# Combined filters + confirmed/pending separation
# ------------------------------------------------------------------
def test_combined_filters_and_state_split(self) -> None:
self._seed_mixed()
status, payload = self._events(self.cashier_a)
self.assertEqual(200, status, payload)
events = payload["events"]
# A sees: out 100, in 40, locked out 25, pending out 7 — not B↔C 200
self.assertEqual(4, len(events))
amounts = {e["amount"] for e in events}
self.assertEqual({"100.00", "40.00", "25.00", "7.00"}, amounts)
for event in events:
self.assertNotEqual(self.company_c, event["counterparty_company_id"])
self.assertIn(event["state"], ("confirmed", "pending"))
self.assertIn(event["direction"], ("out", "in"))
status, confirmed = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=confirmed"
)
self.assertEqual(200, status, confirmed)
self.assertEqual(3, len(confirmed["events"]))
self.assertTrue(all(e["state"] == "confirmed" for e in confirmed["events"]))
self.assertNotIn("7.00", {e["amount"] for e in confirmed["events"]})
status, pending = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending"
)
self.assertEqual(200, status, pending)
self.assertEqual(1, len(pending["events"]))
self.assertEqual("7.00", pending["events"][0]["amount"])
self.assertEqual("pending", pending["events"][0]["state"])
status, outs = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=out&state=confirmed"
)
self.assertEqual(200, status, outs)
self.assertEqual({"100.00", "25.00"}, {e["amount"] for e in outs["events"]})
self.assertTrue(all(e["direction"] == "out" for e in outs["events"]))
status, by_cp = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_b}&state=confirmed",
)
self.assertEqual(200, status, by_cp)
self.assertEqual(3, len(by_cp["events"]))
# Date window excludes Feb locked/pending
status, jan = self._events(
self.cashier_a, "from=2026-01-01&to=2026-01-31&state=confirmed"
)
self.assertEqual(200, status, jan)
self.assertEqual({"100.00", "40.00"}, {e["amount"] for e in jan["events"]})
def test_keyset_pagination_no_dup_no_gap(self) -> None:
# Three confirmed A→B outs on distinct days
for i, amount in enumerate(("11.00", "12.00", "13.00", "14.00", "15.00")):
day = 5 + i
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, f"2026-01-{day:02d} 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, amount, f"2026-01-{day:02d} 11:00:00")],
)
status, page1 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out&limit=2",
)
self.assertEqual(200, status, page1)
self.assertEqual(2, len(page1["events"]))
self.assertTrue(page1["has_more"])
self.assertIsNotNone(page1["next_cursor"])
status, page2 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out"
f"&limit=2&cursor={page1['next_cursor']}",
)
self.assertEqual(200, status, page2)
self.assertEqual(2, len(page2["events"]))
self.assertTrue(page2["has_more"])
status, page3 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out"
f"&limit=2&cursor={page2['next_cursor']}",
)
self.assertEqual(200, status, page3)
self.assertEqual(1, len(page3["events"]))
self.assertFalse(page3["has_more"])
self.assertIsNone(page3["next_cursor"])
ids = [e["event_id"] for e in page1["events"] + page2["events"] + page3["events"]]
self.assertEqual(5, len(ids))
self.assertEqual(len(ids), len(set(ids)))
# Descending by effective_at then event_id
amounts = [e["amount"] for e in page1["events"] + page2["events"] + page3["events"]]
self.assertEqual(["15.00", "14.00", "13.00", "12.00", "11.00"], amounts)
# ------------------------------------------------------------------
# Isolation / ID guess / detail reuse
# ------------------------------------------------------------------
def test_lateral_isolation_and_id_guess_404(self) -> None:
self._seed_mixed()
status, payload_a = self._events(self.cashier_a)
self.assertEqual(200, status, payload_a)
a_ids = {e["event_id"] for e in payload_a["events"]}
status, payload_c = self._events(self.cashier_c)
self.assertEqual(200, status, payload_c)
# C only participates in B↔C 200
self.assertTrue(payload_c["events"])
for event in payload_c["events"]:
self.assertEqual("200.00", event["amount"])
self.assertNotIn(event["event_id"], a_ids)
# C guessing A's event id via transfer-events detail → 404
a_event_id = next(iter(a_ids))
status, _, data = self.cashier_c.get(
f"/api/company/transfer-events/{a_event_id}"
)
self.assertEqual(404, status, data)
# A can open own event; counterparty account masked; only own observations
status, _, data = self.cashier_a.get(
f"/api/company/transfer-events/{a_event_id}"
)
self.assertEqual(200, status, data)
detail = as_json(data)["event"]
if detail.get("counterparty") and "account_number_masked" in detail["counterparty"]:
masked = detail["counterparty"]["account_number_masked"]
self.assertTrue(str(masked).startswith("****"))
self.assertNotIn(ACCOUNT_B, masked)
for obs in detail["observations"]:
self.assertEqual(self.company_a, obs["batch_company_id"])
self.assertNotIn(ACCOUNT_B, json.dumps(obs, ensure_ascii=False))
# Filtering by counterparty C still cannot leak B↔C into A's list
status, filtered = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_c}",
)
self.assertEqual(200, status, filtered)
self.assertEqual([], filtered["events"])
def test_b44_events_path_still_works_with_cutoff(self) -> None:
# Without HEL-176 discriminators, /events stays on B-44 ledger list.
status, _, data = self.cashier_a.get(
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertIn("items", payload)
self.assertNotIn("events", payload)
# ------------------------------------------------------------------
# Export: confirmed only + audit + isolation
# ------------------------------------------------------------------
def test_export_confirmed_only_isolated_and_audited(self) -> None:
self._seed_mixed()
status, headers, data = self._export(self.cashier_a)
self.assertEqual(200, status, data)
self.assertEqual("text/csv; charset=utf-8", headers.get("content-type"))
text = data.decode("utf-8-sig")
lines = [line for line in text.splitlines() if line]
self.assertGreaterEqual(len(lines), 2)
body = "\n".join(lines[1:])
self.assertIn("100.00", body)
self.assertIn("40.00", body)
self.assertIn("25.00", body)
self.assertNotIn("7.00", body) # pending excluded
self.assertNotIn("200.00", body) # B↔C excluded
# Explicit pending state rejected
status, _, data = self._export(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending"
)
self.assertEqual(400, status, data)
# C export must not contain A's amounts
status, _, data = self._export(self.cashier_c)
self.assertEqual(200, status, data)
text_c = data.decode("utf-8-sig")
self.assertNotIn("100.00", text_c)
self.assertNotIn("25.00", text_c)
self.assertIn("200.00", text_c)
status, _, data = self.admin.get("/api/admin/audit-log?limit=50")
self.assertEqual(200, status, data)
actions = [row["action"] for row in as_json(data)["entries"]]
self.assertIn("export_intercompany_csv", actions)
export_rows = [
row
for row in as_json(data)["entries"]
if row["action"] == "export_intercompany_csv"
]
self.assertTrue(export_rows)
self.assertTrue(
any(f"company:{self.company_a}" == row.get("target") for row in export_rows)
)
self.assertTrue(any("state:confirmed" in (row.get("detail") or "") for row in export_rows))
if __name__ == "__main__":
unittest.main()
+362
View File
@@ -0,0 +1,362 @@
"""HTTP tests for GET /api/company/intercompany/summary (HEL-175).
Covers session-scoped company_id, forged company_id rejection, confirmed vs
pending separation, Decimal net math, dual-company isolation and empty data.
"""
from __future__ import annotations
import io
import json
import os
from decimal import Decimal
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]
class CompanyIntercompanySummaryTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self._old_db_path = server.DB_PATH
self._old_storage = server.STORAGE_DIR
server.DB_PATH = self.db_path
server.STORAGE_DIR = self.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(self.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
self.port = self.httpd.server_address[1]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.admin = Client("127.0.0.1", self.port)
status, _, data = self.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = self.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
self.initial_passwords: dict[str, str] = {}
self.company_a = self._create_company("甲公司", "cashier-a")
self.company_b = self._create_company("乙公司", "cashier-b")
self.company_c = self._create_company("丙公司", "cashier-c")
self.cashier_a = self._login_company("cashier-a")
self.cashier_b = self._login_company("cashier-b")
self.cashier_c = self._login_company("cashier-c")
self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a)
self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b)
self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c)
def tearDown(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()
server.DB_PATH = self._old_db_path
server.STORAGE_DIR = self._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
def _create_company(self, name: str, username: str) -> int:
status, _, data = self.admin.post_json(
"/api/admin/companies", {"name": name, "username": username}
)
assert status == 200, data
self.initial_passwords[username] = as_json(data)["initial_password"]
return as_json(data)["company_id"]
def _login_company(self, username: str) -> Client:
client = Client("127.0.0.1", self.port)
initial = self.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
def _approve_account(self, company_id: int, number: str, client: Client) -> int:
status, _, data = client.post_json(
"/api/company/accounts",
{
"bank_name": "中信银行",
"account_type": "基本户",
"account_number": number,
"start_date": "2026-01-01",
},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{
"decision": "approve",
"reason": "测试启用",
"effective_from": "2026-01-01",
},
)
assert status == 200, data
return account_id
def _upload_and_confirm(self, client: Client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
def _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict:
"""A-side only upload → admin locks as intercompany single."""
self._upload_and_confirm(
self.cashier_a,
self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
)
status, _, data = self.admin.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
single = next(
e
for e in as_json(data)["events"]
if e["status"] == "internal_single" and e["amount"] == amount
)
status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}")
self.assertEqual(200, status, data)
revision = as_json(data)["event"]["revision"]
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{
"action": "assign_participant",
"reason": "函证确认",
"expected_revision": revision,
"request_key": f"lock-{amount}-{at}",
"participant": {"role": "payee", "company_id": self.company_b},
},
)
self.assertEqual(200, status, data)
return as_json(data)["decision"]
def _summary(self, client: Client, query: str = "as_of=2026-12-31"):
status, _, data = client.get(f"/api/company/intercompany/summary?{query}")
return status, as_json(data) if data else {}
# ------------------------------------------------------------------
# Auth / parameter guards
# ------------------------------------------------------------------
def test_requires_company_role(self) -> None:
status, payload = self._summary(self.admin)
self.assertEqual(403, status, payload)
def test_rejects_forged_company_id(self) -> None:
status, payload = self._summary(
self.cashier_a, f"as_of=2026-12-31&company_id={self.company_b}"
)
self.assertEqual(400, status, payload)
self.assertIn("company_id", payload.get("message", ""))
def test_rejects_own_company_id_param(self) -> None:
# Even matching the session company is forbidden.
status, payload = self._summary(
self.cashier_a, f"as_of=2026-12-31&company_id={self.company_a}"
)
self.assertEqual(400, status, payload)
def test_rejects_bad_as_of(self) -> None:
status, payload = self._summary(self.cashier_a, "as_of=2026-13-40")
self.assertEqual(400, status, payload)
def test_anonymous_is_unauthorized(self) -> None:
anon = Client("127.0.0.1", self.port)
status, _, data = anon.get("/api/company/intercompany/summary?as_of=2026-12-31")
self.assertIn(status, (401, 403))
# ------------------------------------------------------------------
# Empty / confirmed math / pending isolation
# ------------------------------------------------------------------
def test_empty_window_returns_zeros(self) -> None:
status, payload = self._summary(self.cashier_a)
self.assertEqual(200, status, payload)
self.assertEqual(self.company_a, payload["own_company"]["id"])
self.assertFalse(payload["window"]["has_opening"])
self.assertIsNone(payload["window"]["opening"])
self.assertEqual("0.00", payload["confirmed"]["outflow_total"])
self.assertEqual("0.00", payload["confirmed"]["inflow_total"])
self.assertEqual("0.00", payload["confirmed"]["net_change"])
self.assertEqual(0, payload["pending"]["count"])
self.assertEqual([], payload["counterparties"])
# Amounts must be strings, never floats.
self.assertIsInstance(payload["confirmed"]["net_change"], str)
self.assertNotIsInstance(payload["confirmed"]["net_change"], float)
def test_paired_locked_pending_math_and_isolation(self) -> None:
# Paired A→B 100 + B→A 40 → A net outflow 60
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")],
)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")],
)
# Locked single A→B 25 (confirmed)
self._lock_single("25.00", "2026-02-01 10:00:00")
# Pending unilateral A→B 7 (internal_single, not confirmed)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")],
)
# B↔C paired 200 — must not appear in A's summary
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")],
)
self._upload_and_confirm(
self.cashier_c, self.company_c,
[incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")],
)
status, payload = self._summary(self.cashier_a)
self.assertEqual(200, status, payload)
confirmed = payload["confirmed"]
self.assertEqual("125.00", confirmed["outflow_total"]) # 100 + 25
self.assertEqual(2, confirmed["outflow_count"])
self.assertEqual("40.00", confirmed["inflow_total"])
self.assertEqual(1, confirmed["inflow_count"])
self.assertEqual("85.00", confirmed["net_change"]) # 125 - 40
self.assertEqual("receivable", confirmed["net_direction"])
# Pending tip only — never folded into confirmed totals
self.assertEqual(1, payload["pending"]["count"])
self.assertEqual("7.00", payload["pending"]["amount_total"])
# Decimal identity: net = outflow - inflow, no float drift
net = Decimal(confirmed["net_change"])
self.assertEqual(
Decimal(confirmed["outflow_total"]) - Decimal(confirmed["inflow_total"]),
net,
)
counterparties = {row["company_id"]: row for row in payload["counterparties"]}
self.assertIn(self.company_b, counterparties)
self.assertNotIn(self.company_c, counterparties)
row_b = counterparties[self.company_b]
self.assertEqual("125.00", row_b["confirmed_outflow"])
self.assertEqual("40.00", row_b["confirmed_inflow"])
self.assertEqual("85.00", row_b["net"])
self.assertEqual(1, row_b["pending_count"])
# B must not see C-only? B sees C; A must not see B's C totals via forgery
status_b, payload_b = self._summary(self.cashier_b)
self.assertEqual(200, status_b, payload_b)
cps_b = {row["company_id"] for row in payload_b["counterparties"]}
self.assertIn(self.company_c, cps_b)
# A's view still excludes C
self.assertNotIn(self.company_c, counterparties)
# Same event counted once: eligible event count equals outflow+inflow counts
self.assertEqual(
confirmed["outflow_count"] + confirmed["inflow_count"],
3,
)
def test_company_b_cannot_see_a_only_pending(self) -> None:
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "9.00", "2026-04-01 10:00:00")],
)
status_a, payload_a = self._summary(self.cashier_a)
self.assertEqual(200, status_a, payload_a)
self.assertEqual(1, payload_a["pending"]["count"])
status_c, payload_c = self._summary(self.cashier_c)
self.assertEqual(200, status_c, payload_c)
self.assertEqual(0, payload_c["pending"]["count"])
self.assertEqual("0.00", payload_c["confirmed"]["outflow_total"])
self.assertEqual([], payload_c["counterparties"])
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=13", 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=13"', '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()
+518
View File
@@ -0,0 +1,518 @@
"""HEL-203: 真实浏览器冒烟——原因弹窗、首屏断档、表单 reset。"""
from __future__ import annotations
import os
import re
import tempfile
import threading
import unittest
from pathlib import Path
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "CashierA123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
"""本机缺系统 atk 时,复用仓库旁的本地 chromium 依赖目录。"""
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
class Hel203SourceContractTests(unittest.TestCase):
"""不依赖浏览器:锁住 N1~N3 的源码契约。"""
def test_open_modal_is_single_top_level(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
defs = list(re.finditer(r"(?m)^function openModal\(", js))
self.assertEqual(1, len(defs), "openModal 必须只有一处顶层定义")
# 不得再出现在 initAdmin / initCompany 函数体内的局部副本
self.assertNotRegex(
js,
r"function initAdmin\(\)[\s\S]*?function openModal\(",
)
self.assertNotRegex(
js,
r"function initCompany\(\)[\s\S]*?function openModal\(",
)
self.assertIn("function askReason(", js)
ask_pos = js.index("function askReason(")
open_pos = defs[0].start()
self.assertLess(open_pos, ask_pos, "openModal 须在 askReason 之前定义")
def test_init_company_boots_coverage_gaps(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
company_fn = js[js.index("function initCompany(") :]
boot = company_fn[: company_fn.index("\nif (portal ===")]
self.assertIn("await loadCompanyWorkspace()", boot)
self.assertIn("await loadCompanyCoverageGaps()", boot)
def test_async_forms_capture_form_before_await(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertNotIn("event.currentTarget.reset()", js)
for marker in ("#companyForm", "#openingForm", "#accountForm"):
idx = js.index(marker)
chunk = js[idx : idx + 2500]
self.assertIn("const form = event.currentTarget", chunk)
self.assertIn("form.reset()", chunk)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel203BrowserSmokeTests(unittest.TestCase):
"""真实 Chromium:改起算日→期初→公司端期末;断档首屏→说明→审核。"""
@classmethod
def setUpClass(cls) -> None:
_prepare_chrome_libs()
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls.storage.mkdir()
cls._old_db = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection,
"group-admin",
ADMIN_PASSWORD,
"admin",
must_change_password=False,
)
cls.company_a = master_data.create_company(
connection, "甲公司", None, None, None
)
cls.company_b = master_data.create_company(
connection, "乙公司", None, None, None
)
auth.create_user(
connection,
"cashier-a",
CASHIER_PASSWORD,
"company",
cls.company_a,
must_change_password=False,
)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
account = master_data.submit_bank_account(
connection,
company_id=cls.company_a,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000001",
start_date="2026-06-01",
actor=None,
)
cls.account_a = master_data.review_bank_account(
connection,
account["id"],
"approve",
None,
admin,
effective_from="2026-06-01",
)
account_b = master_data.submit_bank_account(
connection,
company_id=cls.company_b,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000002",
start_date="2026-06-01",
actor=None,
)
master_data.review_bank_account(
connection,
account_b["id"],
"approve",
None,
admin,
effective_from="2026-06-01",
)
# 制造一处 mid 断档,供公司端首屏提醒
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'gap.xlsx', 1, 'data/files/gap.xlsx', ?)
""",
("sha-hel203-gap", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = 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,
cls.company_a,
cls.account_a["id"],
utc_now(),
utc_now(),
),
)
batch_id = int(cursor.lastrowid)
cursor = 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, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
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_batch_id, utc_now()),
)
connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, created_at
) VALUES (?, 1, '2026-06-21T10:00:00', '0', '0', ?, ?)
""",
(sheet_batch_id, "6222000000000001", utc_now()),
)
calculation.set_calculation_start_date(
connection, "2026-06-01", "初始化起算", admin
)
calculation.recalculate_coverage_gaps(connection)
# 种一笔已确认往来,确认期初后公司端才能进入完整期末口径
def _add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
""",
(ref, utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = 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 = 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, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
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_batch_id, utc_now()),
)
cursor = connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, own_name, counterparty_account, counterparty_name,
summary, purpose, currency, created_at
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
""",
(
sheet_batch_id,
at,
income,
expense,
own,
cp,
utc_now(),
),
)
return int(cursor.lastrowid)
row_a = _add_row(
cls.company_a,
cls.account_a["id"],
"6222000000000001",
"6222000000000002",
income="0",
expense="100.00",
at="2026-06-20T10:00:00",
ref="sha-hel203-a",
)
row_b = _add_row(
cls.company_b,
account_b["id"],
"6222000000000002",
"6222000000000001",
income="100.00",
expense="0",
at="2026-06-20T11:00:00",
ref="sha-hel203-b",
)
matching.reconcile_rows(connection, [row_a, row_b])
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
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()
server.DB_PATH = cls._old_db
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def _new_page(self, playwright):
browser = playwright.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
errors: list[str] = []
page.on("pageerror", lambda err: errors.append(str(err)))
page.on(
"console",
lambda msg: errors.append(f"console.{msg.type}: {msg.text}")
if msg.type == "error"
else None,
)
return browser, page, errors
def _login(self, page, *, portal: str, username: str, password: str) -> None:
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.click('button[type="submit"]')
expect = "admin.html" if portal == "admin" else "company.html"
page.wait_for_url(f"**/{expect}", timeout=15000)
def test_02_admin_start_date_opening_company_ending(self) -> None:
with sync_playwright() as p:
browser, page, errors = self._new_page(p)
try:
self._login(
page,
portal="admin",
username="group-admin",
password=ADMIN_PASSWORD,
)
page.click('a[data-view="settings"]')
page.wait_for_selector("#cs-start", state="visible")
# 修改起算日 → 原因弹窗必须打开且发出 PUT
page.fill("#cs-start", "2026-06-15")
with page.expect_request(
lambda req: req.method == "PUT"
and "/api/admin/settings/calculation-start" in req.url
) as start_req:
page.click('#systemSettings button[type="submit"]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "调整起算日供冒烟")
page.click("#reasonSubmit")
self.assertTrue(start_req.value.post_data)
page.wait_for_function(
"() => document.getElementById('cs-start')?.value === '2026-06-15'"
)
# 创建期初并确认
page.click("#openOpeningDialog")
page.wait_for_selector("#openingDialog.open")
page.select_option("#ob-from", label="甲公司")
page.select_option("#ob-to", label="乙公司")
page.fill("#ob-amount", "200")
page.fill("#ob-reason", "冒烟期初录入")
with page.expect_request(
lambda req: req.method == "POST"
and req.url.endswith("/api/admin/opening-balances")
):
page.click('#openingForm button[type="submit"]')
page.wait_for_selector(
'#openingRows button[data-confirm-opening]',
timeout=8000,
)
with page.expect_request(
lambda req: req.method == "POST"
and "/opening-balances/" in req.url
and req.url.endswith("/confirm")
):
page.click('#openingRows button[data-confirm-opening]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "确认期初冒烟")
page.click("#reasonSubmit")
page.wait_for_selector(
'#openingRows button[data-void-opening]',
timeout=8000,
)
# 公司端看到完整期末口径
self._login(
page,
portal="company",
username="cashier-a",
password=CASHIER_PASSWORD,
)
page.click('a[data-view="transfers"]')
page.wait_for_function(
"""() => {
const data = document.getElementById('transfersData');
const empty = document.getElementById('transfersEmpty');
const ready = (data && !data.hidden) || (empty && !empty.hidden);
const card = document.getElementById('tfStatEndingCard');
const title = document.getElementById('tfStatNetTitle');
const emptyHtml = document.getElementById('transfersEmptyStats')?.innerHTML || '';
return ready && (
(card && !card.hidden) ||
(title && title.textContent.includes('期末')) ||
emptyHtml.includes('期末')
);
}""",
timeout=15000,
)
fatal = [e for e in errors if "openModal is not defined" in e
or "Cannot read properties of null" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
def test_01_company_gap_notice_then_admin_approve(self) -> None:
with sync_playwright() as p:
browser, page, errors = self._new_page(p)
try:
self._login(
page,
portal="company",
username="cashier-a",
password=CASHIER_PASSWORD,
)
# 首屏即可见断档提醒,无需手动刷新
page.wait_for_selector(
"#companyCoverageNotice",
state="visible",
timeout=10000,
)
body = page.locator("#companyCoverageBody").inner_text()
self.assertTrue(body.strip())
self.assertNotIn("0002", body)
page.click("#openAttestationFromWorkspace")
page.wait_for_selector("#attestationDialog.open", timeout=5000)
page.fill("#att-reason", "节假日账户无资金往来")
with page.expect_request(
lambda req: req.method == "POST"
and req.url.endswith("/api/company/no-business-attestations")
) as att_req:
page.click('#attestationForm button[type="submit"]')
self.assertTrue(att_req.value.post_data)
page.wait_for_function(
"""() => !document.getElementById('attestationDialog')?.classList.contains('open')""",
timeout=8000,
)
self._login(
page,
portal="admin",
username="group-admin",
password=ADMIN_PASSWORD,
)
page.click('a[data-view="audit"]')
page.wait_for_selector(
'button[data-audit-action="approve-attestation"]',
timeout=10000,
)
with page.expect_request(
lambda req: req.method == "POST"
and "/no-business-attestations/" in req.url
and req.url.endswith("/review")
):
page.click('button[data-audit-action="approve-attestation"]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "审核通过说明")
page.click("#reasonSubmit")
page.wait_for_function(
"""() => !document.querySelector(
'button[data-audit-action=\"approve-attestation\"]'
)""",
timeout=10000,
)
fatal = [e for e in errors if "openModal is not defined" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
if __name__ == "__main__":
unittest.main()
+417
View File
@@ -0,0 +1,417 @@
"""HEL-206: 公司间期末余额方向——期初应收 + 转出垫付 = 期末应收增加。"""
from __future__ import annotations
import os
import tempfile
import threading
import unittest
from decimal import Decimal
from pathlib import Path
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
ROOT = Path(__file__).resolve().parents[1]
ADMIN_PASSWORD = "AdminPass123"
CASHIER_A_PASSWORD = "CashierA123"
CASHIER_B_PASSWORD = "CashierB123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
class Hel206BalanceDirectionUnitTests(unittest.TestCase):
"""不依赖浏览器:期初 200 + 转出 100 → 甲应收 300 / 乙应付 300。"""
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)
auth.create_user(self.connection, "admin-u", ADMIN_PASSWORD, "admin")
self.admin = self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
self.company_a = master_data.create_company(
self.connection, "甲公司", None, None, None
)
self.company_b = master_data.create_company(
self.connection, "乙公司", None, None, None
)
self.account_a = self._approve("6222000000000001", self.company_a)
self.account_b = self._approve("6222000000000002", self.company_b)
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "起算", self.admin
)
def _approve(self, number: str, company_id: int):
account = master_data.submit_bank_account(
self.connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date="2026-01-01",
actor=None,
)
return master_data.review_bank_account(
self.connection,
account["id"],
"approve",
None,
self.admin,
effective_from="2026-01-01",
)
def _add_row(self, company_id, account_id, own, cp, *, income, expense, at, ref):
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
""",
(ref, 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, 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_batch_id, utc_now()),
)
cursor = self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, own_name, counterparty_account, counterparty_name,
summary, purpose, currency, created_at
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
""",
(sheet_batch_id, at, income, expense, own, cp, utc_now()),
)
return int(cursor.lastrowid)
def test_opening_200_plus_outflow_100_equals_closing_300(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
)
row_a = self._add_row(
self.company_a,
self.account_a["id"],
"6222000000000001",
"6222000000000002",
income="0",
expense="100.00",
at="2026-01-15T10:00:00",
ref="sha-hel206-a",
)
row_b = self._add_row(
self.company_b,
self.account_b["id"],
"6222000000000002",
"6222000000000001",
income="100.00",
expense="0",
at="2026-01-15T11:00:00",
ref="sha-hel206-b",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
bal_a = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
bal_b = calculation.compute_pair_balance(
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
)
self.assertEqual("200", bal_a["opening"])
self.assertEqual("100.00", bal_a["net_change"])
self.assertEqual("300.00", bal_a["closing"])
self.assertEqual("-200", bal_b["opening"])
self.assertEqual("-100.00", bal_b["net_change"])
self.assertEqual("-300.00", bal_b["closing"])
self.assertEqual(
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
Decimal("0"),
)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel206BrowserDirectionTests(unittest.TestCase):
"""真实 Chromium:公司端转账往来页期末方向与对手公司对称。"""
@classmethod
def setUpClass(cls) -> None:
_prepare_chrome_libs()
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls.storage.mkdir()
cls._old_db = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection, "group-admin", ADMIN_PASSWORD, "admin",
must_change_password=False,
)
cls.company_a = master_data.create_company(
connection, "甲公司", None, None, None
)
cls.company_b = master_data.create_company(
connection, "乙公司", None, None, None
)
auth.create_user(
connection, "cashier-a", CASHIER_A_PASSWORD, "company",
cls.company_a, must_change_password=False,
)
auth.create_user(
connection, "cashier-b", CASHIER_B_PASSWORD, "company",
cls.company_b, must_change_password=False,
)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
def approve(company_id, number):
account = master_data.submit_bank_account(
connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date="2026-01-01",
actor=None,
)
return master_data.review_bank_account(
connection, account["id"], "approve", None, admin,
effective_from="2026-01-01",
)
account_a = approve(cls.company_a, "6222000000000001")
account_b = approve(cls.company_b, "6222000000000002")
calculation.set_calculation_start_date(
connection, "2026-01-01", "初始化起算", admin
)
item = calculation.create_opening_balance(
connection, cls.company_a, cls.company_b, "200", "期初应收", admin
)
calculation.confirm_opening_balance(connection, item["id"], "确认", admin)
def add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
""",
(ref, utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = 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 = 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, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
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_batch_id, utc_now()),
)
cursor = connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, own_name, counterparty_account, counterparty_name,
summary, purpose, currency, created_at
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
""",
(sheet_batch_id, at, income, expense, own, cp, utc_now()),
)
return int(cursor.lastrowid)
row_a = add_row(
cls.company_a, account_a["id"], "6222000000000001", "6222000000000002",
income="0", expense="100.00", at="2026-01-15T10:00:00", ref="sha-hel206-br-a",
)
row_b = add_row(
cls.company_b, account_b["id"], "6222000000000002", "6222000000000001",
income="100.00", expense="0", at="2026-01-15T11:00:00", ref="sha-hel206-br-b",
)
matching.reconcile_rows(connection, [row_a, row_b])
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
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()
server.DB_PATH = cls._old_db
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def _login(self, page, *, username: str, password: str) -> None:
page.goto(f"{self.base}/login-company.html", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.click('button[type="submit"]')
page.wait_for_url("**/company.html", timeout=15000)
def test_company_ending_direction_in_browser(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
try:
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
errors: list[str] = []
page.on("pageerror", lambda err: errors.append(str(err)))
# 甲公司:期初 200 + 转出 100 → 期末应收 300
self._login(page, username="cashier-a", password=CASHIER_A_PASSWORD)
page.click('a[data-view="transfers"]')
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
bal_a = page.evaluate(
"""async () => {
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
return await res.json();
}"""
)
self.assertEqual("ok", bal_a.get("status"))
self.assertEqual("full", bal_a.get("basis"))
pair_a = next(
p for p in bal_a["pairs"]
if p["counterparty_company_id"] == self.company_b
)
self.assertEqual("200", pair_a["opening"])
self.assertEqual("100.00", pair_a["net_change"])
self.assertEqual("300.00", pair_a["closing"])
ending_text = page.locator("#tfStatEnding").inner_text()
self.assertIn("0.03", ending_text) # 300 元 = 0.03 万元
self.assertEqual([], errors, errors)
# 乙公司:对称应付 300
self._login(page, username="cashier-b", password=CASHIER_B_PASSWORD)
page.click('a[data-view="transfers"]')
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
bal_b = page.evaluate(
"""async () => {
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
return await res.json();
}"""
)
pair_b = next(
p for p in bal_b["pairs"]
if p["counterparty_company_id"] == self.company_a
)
self.assertEqual("-200", pair_b["opening"])
self.assertEqual("-100.00", pair_b["net_change"])
self.assertEqual("-300.00", pair_b["closing"])
ending_b = page.locator("#tfStatEnding").inner_text()
self.assertIn("0.03", ending_b)
fatal = [e for e in errors if "is not defined" in e or "Cannot read" in e]
self.assertEqual([], fatal, fatal)
finally:
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,
+47 -27
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">
@@ -942,6 +962,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=9"></script>
<script src="app.js?v=13"></script>
</body>
</html>
+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=13"></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); }
}