Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27016d71d | ||
|
|
96309eb2cc | ||
|
|
40f7a91a0f | ||
|
|
cad12b3d28 | ||
|
|
7e7354a809 | ||
|
|
642604f688 | ||
|
|
551909ad0c |
@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
|
||||
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
||||
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
||||
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
||||
内网测试环境可设 `APP_LOGIN_RATE_LIMIT_DISABLED=1` 暂时关闭该锁定
|
||||
(默认未设置 = 保持限流,正式环境不得开启该变量)。
|
||||
|
||||
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
|
||||
且在窗口内不再记录新尝试,行为确定、可测试。
|
||||
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
|
||||
- 运维开关:环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
|
||||
时完全跳过限流检查,仅限内网测试环境临时使用;默认未设置,保持
|
||||
「5 次失败锁 10 分钟」的生产安全策略不变。
|
||||
|
||||
## 角色与公司绑定
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -11,8 +11,8 @@ 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, 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
|
||||
|
||||
@@ -155,6 +155,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 +172,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)
|
||||
@@ -2426,10 +2439,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 +2461,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 +2880,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:
|
||||
|
||||
+25
-12
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import string
|
||||
@@ -28,6 +29,17 @@ RATE_LIMIT_WINDOW_MINUTES = 10
|
||||
INITIAL_PASSWORD_LENGTH = 12
|
||||
|
||||
|
||||
def _env_flag(value: str | None) -> bool:
|
||||
"""Parse a yes/no style environment flag; blank/absent means False."""
|
||||
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# Ops switch for internal test environments: APP_LOGIN_RATE_LIMIT_DISABLED=1
|
||||
# turns off the login-failure lockout entirely. The default (unset) keeps the
|
||||
# production policy — 5 failures within 10 minutes lock the (账号, IP) pair.
|
||||
RATE_LIMIT_DISABLED = _env_flag(os.environ.get("APP_LOGIN_RATE_LIMIT_DISABLED"))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||
salt = secrets.token_bytes(16)
|
||||
@@ -146,18 +158,19 @@ def authenticate(
|
||||
Every non-rate-limited attempt is recorded in ``login_attempts`` and
|
||||
``audit_log``; the password itself is never stored anywhere.
|
||||
"""
|
||||
window_start = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
||||
).isoformat()
|
||||
failures = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM login_attempts
|
||||
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
||||
""",
|
||||
(username, ip, window_start),
|
||||
).fetchone()
|
||||
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
||||
return None, "rate_limited"
|
||||
if not RATE_LIMIT_DISABLED:
|
||||
window_start = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
||||
).isoformat()
|
||||
failures = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM login_attempts
|
||||
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
||||
""",
|
||||
(username, ip, window_start),
|
||||
).fetchone()
|
||||
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
||||
return None, "rate_limited"
|
||||
|
||||
user = connection.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
"""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 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()
|
||||
start = 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"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"own_company": own,
|
||||
"window": {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"has_opening": False,
|
||||
# Reserved for opening-balance rollout; callers must not invent balances.
|
||||
"opening": None,
|
||||
"ending": None,
|
||||
},
|
||||
"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
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from bank_importer import auth
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
@@ -167,6 +168,40 @@ class AuthenticateTests(AuthTestCase):
|
||||
self.assertIsNotNone(user)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_env_flag_parses_truthy_and_falsy_values(self) -> None:
|
||||
cases = {
|
||||
"1": True,
|
||||
"true": True,
|
||||
"YES": True,
|
||||
" on ": True,
|
||||
"": False,
|
||||
"0": False,
|
||||
"false": False,
|
||||
"off": False,
|
||||
None: False,
|
||||
}
|
||||
for value, expected in cases.items():
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(expected, auth._env_flag(value))
|
||||
|
||||
def test_rate_limit_enabled_by_default(self) -> None:
|
||||
self.assertFalse(auth.RATE_LIMIT_DISABLED)
|
||||
|
||||
def test_env_switch_disables_rate_limit_but_not_credential_checks(self) -> None:
|
||||
self.create_company_user()
|
||||
with mock.patch.object(auth, "RATE_LIMIT_DISABLED", True):
|
||||
for _ in range(auth.RATE_LIMIT_MAX_FAILURES + 2):
|
||||
user, reason = auth.authenticate(
|
||||
self.connection, "cashier-a", "Wrong999", "10.0.0.1"
|
||||
)
|
||||
self.assertIsNone(user)
|
||||
self.assertEqual("bad_credentials", reason)
|
||||
user, reason = auth.authenticate(
|
||||
self.connection, "cashier-a", "Init1234", "10.0.0.1"
|
||||
)
|
||||
self.assertIsNotNone(user)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
|
||||
class SessionTests(AuthTestCase):
|
||||
def test_create_and_resolve_roundtrip(self) -> None:
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""HEL-174: 公司端往来确认完成态用 success 绿,待确认保留 warn 黄。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import unittest
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError: # pragma: no cover - 默认测试环境无浏览器依赖
|
||||
sync_playwright = None
|
||||
|
||||
|
||||
class ConfirmStatusSourceContractTests(unittest.TestCase):
|
||||
"""不依赖浏览器:锁住完成态切绿 / 待确认仍黄的实现契约。"""
|
||||
|
||||
def test_app_js_toggles_success_green_when_pending_zero(self) -> None:
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
self.assertIn('pill ${total ? "pill-warn" : "pill-success"}', js)
|
||||
self.assertIn('flowStep.classList.toggle("doing", pending > 0)', js)
|
||||
self.assertIn('flowStep.classList.toggle("done", pending === 0)', js)
|
||||
self.assertIn('notice.classList.toggle("warn", pending > 0)', js)
|
||||
self.assertIn('notice.classList.toggle("success", pending === 0)', js)
|
||||
# 文案:完成=已完成,不把待确认一并改绿
|
||||
self.assertRegex(js, r'status\.textContent = total \? `\$\{total\} 项待处理` : "已完成"')
|
||||
self.assertRegex(js, r'flowState\.textContent = pending \? `待处理 \$\{pending\} 笔` : "已完成"')
|
||||
|
||||
def test_design_tokens_map_done_to_success_doing_to_warn(self) -> None:
|
||||
css = (WEB / "design-system.css").read_text(encoding="utf-8")
|
||||
self.assertIn("--success:", css)
|
||||
self.assertIn("--warn:", css)
|
||||
self.assertIn(".flow-step.done .fs-dot { background: var(--success); }", css)
|
||||
self.assertIn(".flow-step.done .fs-state { color: var(--success); }", css)
|
||||
self.assertIn(".flow-step.doing .fs-dot { background: var(--warn);", css)
|
||||
self.assertIn(".pill-success { background: var(--success-soft); color: var(--success); }", css)
|
||||
self.assertIn(".pill-warn { background: var(--warn-soft);", css)
|
||||
self.assertIn(".notice.success { background: var(--success-soft);", css)
|
||||
self.assertIn(".notice.warn { background: var(--warn-soft);", css)
|
||||
|
||||
def test_company_html_exposes_flow_step_and_cache_bust(self) -> None:
|
||||
html = (WEB / "company.html").read_text(encoding="utf-8")
|
||||
self.assertIn('id="workspaceConfirmState"', html)
|
||||
self.assertIn('id="workspacePendingStatus"', html)
|
||||
self.assertIn('id="workspaceFlowSub"', html)
|
||||
self.assertIn('data-view-link="reconcile"', html)
|
||||
self.assertIn("app.js?v=12", html)
|
||||
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
|
||||
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
|
||||
|
||||
|
||||
def _extract_fn(source: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = source.index(marker)
|
||||
depth = 0
|
||||
for i, ch in enumerate(source[start:], start):
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start : i + 1]
|
||||
raise AssertionError(f"未能截取 function {name}")
|
||||
|
||||
|
||||
def _chromium_available() -> bool:
|
||||
"""本机缺 libatk 等系统库时 chromium 无法启动。"""
|
||||
try:
|
||||
import ctypes.util
|
||||
|
||||
return bool(ctypes.util.find_library("atk-1.0"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验")
|
||||
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过浏览器色值校验")
|
||||
class ConfirmStatusDomTests(unittest.TestCase):
|
||||
"""真实浏览器:pending>0 为黄,pending=0 为绿。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
|
||||
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.base = f"http://127.0.0.1:{cls.port}"
|
||||
app_js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
cls.inject_js = "\n".join(
|
||||
[
|
||||
"const state = {};",
|
||||
"function $(sel, root) { return (root || document).querySelector(sel); }",
|
||||
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
|
||||
_extract_fn(app_js, "formatWorkspaceAmount"),
|
||||
_extract_fn(app_js, "applyCompanyWorkspace"),
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
|
||||
def _open_fixture(self, page):
|
||||
page.goto(f"{self.base}/design-system.css", wait_until="domcontentloaded")
|
||||
page.set_content(
|
||||
f"""<!doctype html>
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="stylesheet" href="{self.base}/design-system.css"/>
|
||||
</head>
|
||||
<body data-portal="company">
|
||||
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
|
||||
<div class="card" id="workspaceTodos">
|
||||
<div class="card-head">
|
||||
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">…</span></span>
|
||||
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
|
||||
</div>
|
||||
<div id="workspaceTodoList"></div>
|
||||
<div class="table-foot" id="workspaceTodoFoot"><span>…</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期流程<span class="sub" id="workspaceFlowSub">…</span></span>
|
||||
</div>
|
||||
<div class="flow">
|
||||
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
|
||||
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
|
||||
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
|
||||
<div class="fs-meta" id="workspaceConfirmMeta">…</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice warn" id="blocking-notice">
|
||||
<div class="n-title" id="notice-title">…</div>
|
||||
<div class="n-body" id="notice-body">…</div>
|
||||
</div>
|
||||
<span class="tab-count" id="count-match">0</span>
|
||||
<nav class="side-nav"><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
|
||||
</body></html>""",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
page.add_script_tag(content=self.inject_js)
|
||||
page.wait_for_function("() => typeof applyCompanyWorkspace === 'function'")
|
||||
|
||||
@staticmethod
|
||||
def _colors(page) -> dict:
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
|
||||
const state = document.getElementById('workspaceConfirmState');
|
||||
const pill = document.getElementById('workspacePendingStatus');
|
||||
const notice = document.getElementById('blocking-notice');
|
||||
const cs = (el) => getComputedStyle(el);
|
||||
return {
|
||||
stepClass: step.className,
|
||||
stateText: state.textContent,
|
||||
stateColor: cs(state).color,
|
||||
pillClass: pill.className,
|
||||
pillColor: cs(pill).color,
|
||||
pillText: pill.textContent,
|
||||
noticeClass: notice.className,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
||||
def test_pending_stays_warn_completed_turns_success(self) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
self._open_fixture(page)
|
||||
|
||||
page.evaluate(
|
||||
"""() => applyCompanyWorkspace({
|
||||
pending_unilateral: 2,
|
||||
pending_total: 2,
|
||||
unilateral_events: [
|
||||
{event_id: 1, amount: '100.00', currency: 'CNY',
|
||||
counterparty_company_name: '乙', effective_at: '2026-07-01'}
|
||||
]
|
||||
})"""
|
||||
)
|
||||
pending = self._colors(page)
|
||||
self.assertIn("doing", pending["stepClass"])
|
||||
self.assertNotIn("done", pending["stepClass"].split())
|
||||
self.assertIn("pill-warn", pending["pillClass"])
|
||||
self.assertNotIn("pill-success", pending["pillClass"])
|
||||
self.assertIn("warn", pending["noticeClass"].split())
|
||||
self.assertIn("待处理", pending["stateText"])
|
||||
self.assertIn("待处理", pending["pillText"])
|
||||
|
||||
page.evaluate(
|
||||
"""() => applyCompanyWorkspace({
|
||||
pending_unilateral: 0,
|
||||
pending_total: 0,
|
||||
unilateral_events: []
|
||||
})"""
|
||||
)
|
||||
done = self._colors(page)
|
||||
self.assertIn("done", done["stepClass"])
|
||||
self.assertNotIn("doing", done["stepClass"].split())
|
||||
self.assertIn("pill-success", done["pillClass"])
|
||||
self.assertNotIn("pill-warn", done["pillClass"])
|
||||
self.assertIn("success", done["noticeClass"].split())
|
||||
self.assertEqual("已完成", done["stateText"])
|
||||
self.assertEqual("已完成", done["pillText"])
|
||||
|
||||
self.assertNotEqual(pending["stateColor"], done["stateColor"])
|
||||
self.assertNotEqual(pending["pillColor"], done["pillColor"])
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""HEL-177: 公司端转账往来页面(方案 A)结构与接口契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import unittest
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError: # pragma: no cover
|
||||
sync_playwright = None
|
||||
|
||||
|
||||
class TransfersPageSourceContractTests(unittest.TestCase):
|
||||
def test_company_html_has_nav_overview_and_layers(self) -> None:
|
||||
html = (WEB / "company.html").read_text(encoding="utf-8")
|
||||
self.assertIn('data-view="transfers"', html)
|
||||
self.assertIn("转账往来", html)
|
||||
self.assertIn('id="workspaceTransfersCard"', html)
|
||||
self.assertIn("转账往来概览", html)
|
||||
self.assertIn('data-page="transfers"', html)
|
||||
self.assertIn('id="transfersOverviewLayer"', html)
|
||||
self.assertIn('id="transfersDetailLayer"', html)
|
||||
self.assertIn('id="transferEvidenceDrawer"', html)
|
||||
self.assertIn("期间净变动", html)
|
||||
self.assertNotIn("本公司往来合计", html)
|
||||
self.assertIn("design-system.css?v=6", html)
|
||||
self.assertIn("app.js?v=12", html)
|
||||
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
|
||||
flows = html.index('data-view="flows"')
|
||||
transfers = html.index('data-view="transfers"')
|
||||
reconcile = html.index('data-view="reconcile"')
|
||||
self.assertLess(flows, transfers)
|
||||
self.assertLess(transfers, reconcile)
|
||||
|
||||
def test_app_js_wires_real_apis_no_demo_totals(self) -> None:
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
self.assertIn('transfers: "转账往来"', js)
|
||||
self.assertIn("/api/company/intercompany/summary", js)
|
||||
self.assertIn("/api/company/intercompany/events", js)
|
||||
self.assertIn("/api/company/intercompany/export.csv", js)
|
||||
self.assertIn("/api/company/transfer-events/", js)
|
||||
self.assertIn("function loadTransfersSummary", js)
|
||||
self.assertIn("function openTransfersDetail", js)
|
||||
self.assertIn("function openTransferEvidence", js)
|
||||
self.assertIn("function initTransfers", js)
|
||||
self.assertIn("initTransfers()", js)
|
||||
self.assertIn("期间净变动", js)
|
||||
self.assertIn("has_opening", js)
|
||||
# 不得把「期末余额」写死为无期初时的标签
|
||||
self.assertNotRegex(js, r'netLabelForWindow[^{]+{[^}]*return "期末余额"')
|
||||
|
||||
def test_design_system_scopes_info_to_transfers_module(self) -> None:
|
||||
css = (WEB / "design-system.css").read_text(encoding="utf-8")
|
||||
self.assertIn('a[data-view="transfers"].active', css)
|
||||
self.assertIn("var(--info-soft)", css)
|
||||
self.assertIn('[data-page="transfers"] .btn-primary', css)
|
||||
self.assertIn(".xfer-split", css)
|
||||
self.assertIn(".xfer-split-pane.confirmed", css)
|
||||
self.assertIn(".xfer-split-pane.pending", css)
|
||||
# 无新色硬编码
|
||||
self.assertNotRegex(css, r"\.xfer-split[^\{]*\{[^}]*#[0-9a-fA-F]{3,8}")
|
||||
|
||||
|
||||
class TransfersDetailObservationContractTests(unittest.TestCase):
|
||||
def test_server_exposes_batch_fields_on_observations(self) -> None:
|
||||
server = (ROOT / "server.py").read_text(encoding="utf-8")
|
||||
self.assertIn("import_batch_id", server)
|
||||
self.assertIn("original_filename", server)
|
||||
self.assertIn("counterparty_account_masked", server)
|
||||
|
||||
|
||||
def _chromium_available() -> bool:
|
||||
try:
|
||||
import ctypes.util
|
||||
|
||||
return bool(ctypes.util.find_library("atk-1.0"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过布局冒烟")
|
||||
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过布局冒烟")
|
||||
class TransfersPageLayoutSmokeTests(unittest.TestCase):
|
||||
"""静态壳:360 / 820 / 1440 无横向溢出。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
|
||||
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.base = f"http://127.0.0.1:{cls.port}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
|
||||
def test_transfers_shell_no_horizontal_overflow(self) -> None:
|
||||
html = (WEB / "company.html").read_text(encoding="utf-8")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
for width in (360, 820, 1440):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.set_content(
|
||||
html.replace('src="app.js?v=12"', 'src=""'),
|
||||
base_url=self.base,
|
||||
)
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelectorAll('.app-view').forEach((el) => {
|
||||
el.classList.toggle('is-active', el.dataset.page === 'transfers');
|
||||
});
|
||||
const data = document.getElementById('transfersData');
|
||||
const loading = document.getElementById('transfersLoading');
|
||||
if (loading) loading.hidden = true;
|
||||
if (data) data.hidden = false;
|
||||
}"""
|
||||
)
|
||||
overflow = page.evaluate(
|
||||
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
|
||||
)
|
||||
self.assertFalse(overflow, f"{width}px 出现横向溢出")
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+577
-2
@@ -4,7 +4,7 @@ const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)]
|
||||
const portal = document.body.dataset.portal || "entry";
|
||||
const viewNames = portal === "admin"
|
||||
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
|
||||
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
|
||||
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", transfers: "转账往来", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
|
||||
|
||||
const storageKeys = {
|
||||
manual: "ledger-demo-manual-records",
|
||||
@@ -177,11 +177,27 @@ function showView(view) {
|
||||
else item.removeAttribute("aria-current");
|
||||
});
|
||||
const title = $("#currentViewName");
|
||||
if (title) title.textContent = viewNames[view];
|
||||
if (title) {
|
||||
if (view === "transfers" && state.transfersDetail?.company_name) {
|
||||
title.textContent = `转账往来 / ${state.transfersDetail.company_name}`;
|
||||
} else {
|
||||
title.textContent = viewNames[view];
|
||||
}
|
||||
}
|
||||
closeNavigation({ restoreFocus: navigationWasOpen });
|
||||
const activeView = $(`.app-view[data-page="${view}"]`);
|
||||
requestAnimationFrame(() => animateView(activeView));
|
||||
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
|
||||
if (portal === "company" && view === "transfers") {
|
||||
if (state.transfersKeepDetail && state.transfersDetail?.company_id) {
|
||||
showTransfersDetailLayer();
|
||||
} else {
|
||||
state.transfersDetail = null;
|
||||
showTransfersOverviewLayer();
|
||||
loadTransfersSummary();
|
||||
}
|
||||
}
|
||||
state.transfersKeepDetail = false;
|
||||
}
|
||||
|
||||
// 原型占位,非本司真待办/真断档数据:detailContent 仅用于演示总览/工作台事项
|
||||
@@ -2619,6 +2635,18 @@ function applyCompanyWorkspace(payload) {
|
||||
? `单边流水 ${pending} 笔待确认`
|
||||
: "已全部确认,等待集团结账";
|
||||
}
|
||||
// 完成态必须切到 success 绿(.flow-step.done),待确认保留 warn 黄(.doing)
|
||||
const flowStep = flowState?.closest(".flow-step");
|
||||
if (flowStep) {
|
||||
flowStep.classList.toggle("doing", pending > 0);
|
||||
flowStep.classList.toggle("done", pending === 0);
|
||||
}
|
||||
const flowSub = $("#workspaceFlowSub");
|
||||
if (flowSub) {
|
||||
flowSub.textContent = pending
|
||||
? "当前停在第 3 步「往来确认」,完成后即可等待集团结账"
|
||||
: "第 3 步「往来确认」已完成,等待集团复核与结账";
|
||||
}
|
||||
|
||||
const countMatch = $("#count-match");
|
||||
if (countMatch) countMatch.textContent = String(pending);
|
||||
@@ -2922,6 +2950,550 @@ function openAccountDetail(account) {
|
||||
$("#modal-account-detail")?.classList.add("open");
|
||||
}
|
||||
|
||||
function yuanToWan(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return n / 10000;
|
||||
}
|
||||
|
||||
function formatWan(value, { signed = false } = {}) {
|
||||
const wan = yuanToWan(value);
|
||||
if (wan === null) return "—";
|
||||
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
if (!signed) return `${abs}<span class="unit">万元</span>`;
|
||||
const sign = wan > 0 ? "+" : wan < 0 ? "−" : "";
|
||||
return `${sign}${abs}<span class="unit">万元</span>`;
|
||||
}
|
||||
|
||||
function formatWanText(value, { signed = false } = {}) {
|
||||
const wan = yuanToWan(value);
|
||||
if (wan === null) return "—";
|
||||
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
if (!signed) return `${abs} 万元`;
|
||||
const sign = wan > 0 ? "+" : wan < 0 ? "−" : "";
|
||||
return `${sign}${abs} 万元`;
|
||||
}
|
||||
|
||||
function netDirectionMeta(netValue, netDirection) {
|
||||
const wan = yuanToWan(netValue);
|
||||
if (wan === null || wan === 0 || !netDirection) {
|
||||
return { label: "持平", className: "flat", signedClass: "" };
|
||||
}
|
||||
if (netDirection === "receivable" || wan > 0) {
|
||||
return { label: "应收", className: "recv", signedClass: "pos" };
|
||||
}
|
||||
return { label: "应付", className: "pay", signedClass: "neg" };
|
||||
}
|
||||
|
||||
function netLabelForWindow(windowInfo) {
|
||||
return windowInfo?.has_opening ? "期末净往来" : "期间净变动";
|
||||
}
|
||||
|
||||
function showTransfersOverviewLayer() {
|
||||
const overview = $("#transfersOverviewLayer");
|
||||
const detail = $("#transfersDetailLayer");
|
||||
if (overview) overview.hidden = false;
|
||||
if (detail) detail.hidden = true;
|
||||
}
|
||||
|
||||
function showTransfersDetailLayer() {
|
||||
const overview = $("#transfersOverviewLayer");
|
||||
const detail = $("#transfersDetailLayer");
|
||||
if (overview) overview.hidden = true;
|
||||
if (detail) detail.hidden = false;
|
||||
}
|
||||
|
||||
function setTransfersUiState(mode) {
|
||||
["transfersLoading", "transfersError", "transfersEmpty", "transfersData"].forEach((id) => {
|
||||
const el = $(`#${id}`);
|
||||
if (el) el.hidden = id !== mode;
|
||||
});
|
||||
}
|
||||
|
||||
function applyTransfersNavBadge(pendingCount) {
|
||||
const badge = $("#transfersNavBadge") || $('.side-nav a[data-view="transfers"] .nav-badge');
|
||||
if (!badge) return;
|
||||
const n = Number(pendingCount) || 0;
|
||||
badge.textContent = String(n);
|
||||
badge.hidden = n <= 0;
|
||||
if (n <= 0) badge.setAttribute("hidden", "");
|
||||
else badge.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
function renderWorkspaceTransfersCard(summary) {
|
||||
const sub = $("#workspaceTransfersSub");
|
||||
if (!sub || !summary) return;
|
||||
const win = summary.window || {};
|
||||
const confirmed = summary.confirmed || {};
|
||||
const pending = summary.pending || {};
|
||||
const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction);
|
||||
sub.textContent = `${win.start || "—"} 至 ${win.end || "—"} · 集团内公司间 · 单位:万元`;
|
||||
const inflow = $("#wsTfIn");
|
||||
const outflow = $("#wsTfOut");
|
||||
const net = $("#wsTfNet");
|
||||
const pendingEl = $("#wsTfPending");
|
||||
const netLabel = $("#wsTfNetLabel");
|
||||
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
|
||||
if (outflow) {
|
||||
outflow.innerHTML = formatWan(
|
||||
confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null,
|
||||
{ signed: true },
|
||||
);
|
||||
// formatWan with negative value already adds −; ensure unit
|
||||
if (confirmed.outflow_total != null) {
|
||||
const wan = yuanToWan(confirmed.outflow_total);
|
||||
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
outflow.innerHTML = `−${abs}<span class="unit">万元</span>`;
|
||||
}
|
||||
}
|
||||
if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`;
|
||||
if (net) {
|
||||
net.className = `ms-value ${netMeta.signedClass}`.trim();
|
||||
net.innerHTML = formatWan(confirmed.net_change, { signed: true });
|
||||
}
|
||||
if (pendingEl) {
|
||||
const pCount = Number(pending.count) || 0;
|
||||
pendingEl.innerHTML = pCount
|
||||
? `${formatWan(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
|
||||
: `0.00<span class="unit">万元 · 0 笔</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTransfersSummary({ asOf } = {}) {
|
||||
if (!$("#transfersOverviewLayer")) return null;
|
||||
setTransfersUiState("transfersLoading");
|
||||
const params = new URLSearchParams();
|
||||
if (asOf) params.set("as_of", asOf);
|
||||
const qs = params.toString();
|
||||
const response = await fetch(`/api/company/intercompany/summary${qs ? `?${qs}` : ""}`).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return null;
|
||||
}
|
||||
const result = await response?.json().catch(() => null);
|
||||
if (!response?.ok || !result || result.status !== "ok") {
|
||||
setTransfersUiState("transfersError");
|
||||
const body = $("#transfersErrorBody");
|
||||
if (body) body.textContent = result?.message || "服务器连接异常。为避免误读,本页不展示任何金额。";
|
||||
return null;
|
||||
}
|
||||
state.transfersSummary = result;
|
||||
applyTransfersNavBadge(result.pending?.count);
|
||||
renderWorkspaceTransfersCard(result);
|
||||
renderTransfersOverview(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderTransfersOverview(summary) {
|
||||
const win = summary.window || {};
|
||||
const confirmed = summary.confirmed || {};
|
||||
const pending = summary.pending || {};
|
||||
const cps = Array.isArray(summary.counterparties) ? summary.counterparties : [];
|
||||
const ownName = summary.own_company?.name || "本公司";
|
||||
const pageSub = $("#transfersPageSub");
|
||||
if (pageSub) {
|
||||
pageSub.textContent = `${ownName} · 统计区间 ${win.start || "—"} 至 ${win.end || "—"},仅含集团内公司间转账(HEL-169 口径)。单位:万元。`;
|
||||
}
|
||||
|
||||
const goConfirm = $("#transfersGoConfirm");
|
||||
const pCount = Number(pending.count) || 0;
|
||||
if (goConfirm) {
|
||||
goConfirm.hidden = pCount <= 0;
|
||||
goConfirm.textContent = pCount ? `去确认待确认 ${pCount} 笔` : "去确认待确认";
|
||||
}
|
||||
|
||||
const hasAny = cps.length > 0 || Number(confirmed.outflow_count || 0) > 0 || Number(confirmed.inflow_count || 0) > 0 || pCount > 0;
|
||||
if (!hasAny) {
|
||||
setTransfersUiState("transfersEmpty");
|
||||
const emptyStats = $("#transfersEmptyStats");
|
||||
if (emptyStats) {
|
||||
emptyStats.innerHTML = `
|
||||
<div class="card stat-card"><div class="stat-label">往来公司数</div><div class="stat-value">0<span class="unit">家</span></div><div class="stat-foot">${win.start || "—"} ~ ${win.end || "—"}</div></div>
|
||||
<div class="card stat-card"><div class="stat-label">本期转入 · 流入</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>
|
||||
<div class="card stat-card"><div class="stat-label">本期转出 · 流出</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>
|
||||
<div class="card stat-card"><div class="stat-label">${netLabelForWindow(win)}</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setTransfersUiState("transfersData");
|
||||
const companies = $("#tfStatCompanies");
|
||||
const inflow = $("#tfStatIn");
|
||||
const outflow = $("#tfStatOut");
|
||||
const net = $("#tfStatNet");
|
||||
const netTitle = $("#tfStatNetTitle");
|
||||
const netFoot = $("#tfStatNetFoot");
|
||||
const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction);
|
||||
if (companies) companies.innerHTML = `${cps.length}<span class="unit">家</span>`;
|
||||
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
|
||||
if (outflow) {
|
||||
const wan = yuanToWan(confirmed.outflow_total);
|
||||
const abs = wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
outflow.innerHTML = wan === null ? "—" : `−${abs}<span class="unit">万元</span>`;
|
||||
}
|
||||
if (netTitle) netTitle.textContent = netLabelForWindow(win);
|
||||
if (net) {
|
||||
const tag = netMeta.label !== "持平"
|
||||
? ` <span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span>`
|
||||
: "";
|
||||
net.innerHTML = `${formatWan(confirmed.net_change, { signed: true })}${tag}`;
|
||||
}
|
||||
if (netFoot) {
|
||||
netFoot.textContent = win.has_opening
|
||||
? "期末 = 期初 + 已确认转出 − 已确认转入"
|
||||
: "起算日未就绪 · 展示期间净变动(转出 − 转入),不得当作期末余额";
|
||||
}
|
||||
|
||||
const confirmedCount = (Number(confirmed.outflow_count) || 0) + (Number(confirmed.inflow_count) || 0);
|
||||
const tfConfirmedCount = $("#tfConfirmedCount");
|
||||
const tfConfirmedNet = $("#tfConfirmedNet");
|
||||
const tfPendingCount = $("#tfPendingCount");
|
||||
const tfPendingAmount = $("#tfPendingAmount");
|
||||
if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount} 笔`;
|
||||
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWan(confirmed.net_change, { signed: true });
|
||||
if (tfPendingCount) tfPendingCount.textContent = `${pCount} 笔`;
|
||||
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0);
|
||||
|
||||
const tbody = $("#transfersCpBody");
|
||||
const tfoot = $("#transfersCpFoot");
|
||||
if (!tbody) return;
|
||||
if (!cps.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="7"><div class="empty" style="border:0;padding:24px 0;"><div class="e-title">暂无对方公司汇总</div></div></td></tr>`;
|
||||
if (tfoot) tfoot.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = cps.map((cp) => {
|
||||
const meta = netDirectionMeta(cp.net, Number(cp.net) > 0 ? "receivable" : Number(cp.net) < 0 ? "payable" : null);
|
||||
const pendingN = Number(cp.pending_count) || 0;
|
||||
const pendingPill = pendingN
|
||||
? `<span class="pill pill-warn">${pendingN} 笔待确认</span>`
|
||||
: `<span class="pill pill-success">全部已确认</span>`;
|
||||
const last = cp.last_effective_at ? String(cp.last_effective_at).slice(0, 10) : "—";
|
||||
const inWan = yuanToWan(cp.confirmed_inflow);
|
||||
const outWan = yuanToWan(cp.confirmed_outflow);
|
||||
const netWan = yuanToWan(cp.net);
|
||||
const fmtAbs = (wan) => wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const inText = inWan === null ? "—" : `+${fmtAbs(inWan)}`;
|
||||
const outText = outWan === null ? "—" : `−${fmtAbs(outWan)}`;
|
||||
const netText = netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "−" : ""}${fmtAbs(netWan)}`;
|
||||
return `<tr class="clickable" data-cp-id="${cp.company_id}" data-cp-name="${String(cp.company_name || "").replace(/"/g, """)}">
|
||||
<td class="cell-main">${cp.company_name || "—"}</td>
|
||||
<td class="num-col amt-in">${inText}</td>
|
||||
<td class="num-col amt-out">${outText}</td>
|
||||
<td class="num-col">${netText}</td>
|
||||
<td><span class="xfer-dir-tag ${meta.className}">${meta.label}</span></td>
|
||||
<td>${pendingPill}</td>
|
||||
<td class="num">${last}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
if (tfoot) {
|
||||
const totalPending = cps.reduce((s, cp) => s + (Number(cp.pending_count) || 0), 0);
|
||||
const inWan = yuanToWan(confirmed.inflow_total);
|
||||
const outWan = yuanToWan(confirmed.outflow_total);
|
||||
const netWan = yuanToWan(confirmed.net_change);
|
||||
const fmtAbs = (wan) => wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
tfoot.innerHTML = `<tr>
|
||||
<td>合计</td>
|
||||
<td class="num-col amt-in">${inWan === null ? "—" : `+${fmtAbs(inWan)}`}</td>
|
||||
<td class="num-col amt-out">${outWan === null ? "—" : `−${fmtAbs(outWan)}`}</td>
|
||||
<td class="num-col">${netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "−" : ""}${fmtAbs(netWan)}`}</td>
|
||||
<td><span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span></td>
|
||||
<td>${totalPending ? `<span class="pill pill-warn">${totalPending} 笔待确认</span>` : `<span class="pill pill-success">全部已确认</span>`}</td>
|
||||
<td></td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function transfersEventQueryParams({ includeCursor = false } = {}) {
|
||||
const detail = state.transfersDetail || {};
|
||||
const filters = detail.filters || {};
|
||||
const params = new URLSearchParams();
|
||||
if (detail.company_id) params.set("counterparty_id", String(detail.company_id));
|
||||
if (filters.from) params.set("from", filters.from);
|
||||
if (filters.to) params.set("to", filters.to);
|
||||
if (filters.direction) params.set("direction", filters.direction);
|
||||
if (filters.state) params.set("state", filters.state);
|
||||
params.set("limit", "50");
|
||||
if (includeCursor && detail.nextCursor) params.set("cursor", detail.nextCursor);
|
||||
return params;
|
||||
}
|
||||
|
||||
async function openTransfersDetail(companyId, companyName, { keepFilters = false } = {}) {
|
||||
const summary = state.transfersSummary;
|
||||
const win = summary?.window || {};
|
||||
const existing = state.transfersDetail;
|
||||
const filters = keepFilters && existing?.filters
|
||||
? { ...existing.filters }
|
||||
: {
|
||||
from: win.start || "",
|
||||
to: win.end || "",
|
||||
direction: "",
|
||||
state: "",
|
||||
};
|
||||
state.transfersDetail = {
|
||||
company_id: Number(companyId),
|
||||
company_name: companyName || "对方公司",
|
||||
filters,
|
||||
nextCursor: null,
|
||||
events: [],
|
||||
};
|
||||
state.transfersKeepDetail = true;
|
||||
showTransfersDetailLayer();
|
||||
const title = $("#currentViewName");
|
||||
if (title) title.textContent = `转账往来 / ${state.transfersDetail.company_name}`;
|
||||
$("#transfersDetailTitle").textContent = state.transfersDetail.company_name;
|
||||
$("#tfFilterFrom").value = filters.from || "";
|
||||
$("#tfFilterTo").value = filters.to || "";
|
||||
$("#tfFilterDirection").value = filters.direction || "";
|
||||
$("#tfFilterState").value = filters.state || "";
|
||||
|
||||
const cp = (summary?.counterparties || []).find((c) => Number(c.company_id) === Number(companyId));
|
||||
const pendingN = Number(cp?.pending_count) || 0;
|
||||
$("#tfDetailCount").innerHTML = `—`;
|
||||
$("#tfDetailCountFoot").textContent = pendingN ? `其中待确认 ${pendingN} 笔(汇总)` : "按筛选加载明细";
|
||||
if (cp) {
|
||||
$("#tfDetailIn").innerHTML = formatWan(cp.confirmed_inflow, { signed: true });
|
||||
const wan = yuanToWan(cp.confirmed_outflow);
|
||||
$("#tfDetailOut").innerHTML = wan === null
|
||||
? "—"
|
||||
: `−${Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}<span class="unit">万元</span>`;
|
||||
}
|
||||
if (state.currentView !== "transfers") {
|
||||
state.transfersKeepDetail = true;
|
||||
showView("transfers");
|
||||
}
|
||||
await loadTransfersEvents({ reset: true });
|
||||
}
|
||||
|
||||
async function loadTransfersEvents({ reset = false } = {}) {
|
||||
if (!state.transfersDetail?.company_id) return;
|
||||
const tbody = $("#transfersEventBody");
|
||||
const empty = $("#transfersEventEmpty");
|
||||
const foot = $("#transfersEventFoot");
|
||||
const moreBtn = $("#transfersLoadMore");
|
||||
if (reset) {
|
||||
state.transfersDetail.nextCursor = null;
|
||||
state.transfersDetail.events = [];
|
||||
if (tbody) showTableLoading(tbody, 6);
|
||||
if (empty) empty.hidden = true;
|
||||
if (moreBtn) moreBtn.hidden = true;
|
||||
}
|
||||
const params = transfersEventQueryParams({ includeCursor: !reset && !!state.transfersDetail.nextCursor });
|
||||
// Always need a distinguishing filter so HEL-176 path is used (counterparty_id is enough)
|
||||
const response = await fetch(`/api/company/intercompany/events?${params}`).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => null);
|
||||
if (!response?.ok || !result || result.status !== "ok") {
|
||||
if (tbody) showTableError(tbody, 6);
|
||||
showToast("明细加载失败", result?.message || "请稍后重试", "danger");
|
||||
return;
|
||||
}
|
||||
const events = Array.isArray(result.events) ? result.events : [];
|
||||
if (reset) state.transfersDetail.events = events;
|
||||
else state.transfersDetail.events = [...(state.transfersDetail.events || []), ...events];
|
||||
state.transfersDetail.nextCursor = result.next_cursor || null;
|
||||
state.transfersDetail.hasMore = !!result.has_more;
|
||||
renderTransfersEvents();
|
||||
if (foot) foot.textContent = `已加载 ${state.transfersDetail.events.length} 笔${result.has_more ? " · 还有更多" : ""}`;
|
||||
if (moreBtn) moreBtn.hidden = !result.has_more;
|
||||
$("#tfDetailCount").innerHTML = `${state.transfersDetail.events.length}${result.has_more ? "+" : ""}<span class="unit">笔</span>`;
|
||||
}
|
||||
|
||||
function transfersStateLabel(event) {
|
||||
if (event.state === "confirmed") {
|
||||
if (event.pairing === "paired") return { pill: "pill-success", text: "已确认 · 双边" };
|
||||
if (event.locked) return { pill: "pill-success", text: "已确认 · 单边锁定" };
|
||||
return { pill: "pill-success", text: "已确认" };
|
||||
}
|
||||
return { pill: "pill-warn", text: "待确认" };
|
||||
}
|
||||
|
||||
function renderTransfersEvents() {
|
||||
const tbody = $("#transfersEventBody");
|
||||
const empty = $("#transfersEventEmpty");
|
||||
if (!tbody) return;
|
||||
const events = state.transfersDetail?.events || [];
|
||||
if (!events.length) {
|
||||
tbody.innerHTML = "";
|
||||
if (empty) empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (empty) empty.hidden = true;
|
||||
tbody.innerHTML = events.map((ev) => {
|
||||
const dir = ev.direction === "out" ? "转出" : ev.direction === "in" ? "转入" : "—";
|
||||
const dirClass = ev.direction === "out" ? "amt-out" : ev.direction === "in" ? "amt-in" : "";
|
||||
const wan = yuanToWan(ev.amount);
|
||||
const amt = wan === null
|
||||
? "—"
|
||||
: `${ev.direction === "out" ? "−" : "+"}${Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const st = transfersStateLabel(ev);
|
||||
const date = String(ev.effective_at || "").slice(0, 10) || "—";
|
||||
const pendingClass = ev.state === "pending" ? " is-pending" : "";
|
||||
return `<tr class="${pendingClass.trim()}" data-event-id="${ev.event_id}">
|
||||
<td class="num">${date}</td>
|
||||
<td class="wrap">${ev.summary || "—"}</td>
|
||||
<td>${dir}${ev.direction === "in" ? " ↓" : ev.direction === "out" ? " ↑" : ""}</td>
|
||||
<td class="num-col ${dirClass}">${amt}</td>
|
||||
<td><span class="pill ${st.pill}">${st.text}</span></td>
|
||||
<td><button type="button" class="btn btn-sm" data-transfer-evidence="${ev.event_id}">原始流水</button></td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function openTransferEvidence(eventId) {
|
||||
const drawer = $("#transferEvidenceDrawer");
|
||||
if (!drawer) return;
|
||||
$("#tfEvTitle").textContent = "加载中…";
|
||||
$("#tfEvDesc").textContent = "";
|
||||
$("#tfEvFields").replaceChildren();
|
||||
drawer.classList.add("is-open");
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
const response = await fetch(`/api/company/transfer-events/${eventId}`).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => null);
|
||||
if (!response?.ok || !result || result.status !== "ok") {
|
||||
$("#tfEvTitle").textContent = "无法打开原始流水";
|
||||
$("#tfEvDesc").textContent = result?.message || "事件不存在或无权查看";
|
||||
return;
|
||||
}
|
||||
const event = result.event || {};
|
||||
const obs = Array.isArray(event.observations) ? event.observations[0] : null;
|
||||
const cp = event.counterparty || {};
|
||||
$("#tfEvTag").className = "pill pill-info";
|
||||
$("#tfEvTag").textContent = "银行原始流水 · 只读";
|
||||
$("#tfEvTitle").textContent = obs?.reference || `事件 #${event.event_id}`;
|
||||
$("#tfEvDesc").textContent = obs?.import_batch_id
|
||||
? `导入批次 IMP-${String(obs.import_batch_id).padStart(6, "0")}${obs.original_filename ? ` · ${obs.original_filename}` : ""}`
|
||||
: "本方银行流水原文";
|
||||
const income = obs?.income != null && Number(obs.income) !== 0;
|
||||
const amountText = obs
|
||||
? `${Number(income ? obs.income : obs.expense).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元(${income ? "收 / 转入" : "付 / 转出"})`
|
||||
: `${event.amount || "—"} ${event.currency || "CNY"}`;
|
||||
const fields = [
|
||||
["本方账户", obs?.own_account_masked || "—"],
|
||||
["交易时间", obs?.transaction_at || event.effective_at || "—"],
|
||||
["方向", income ? "收入(转入)" : "支出(转出)"],
|
||||
["金额", amountText],
|
||||
["对方户名", obs?.counterparty_name || cp.company_name || "—"],
|
||||
["对方账号", obs?.counterparty_account_masked || cp.account_number_masked || "—"],
|
||||
["工作表", obs?.sheet_name || "—"],
|
||||
["原始文件行号", obs?.source_row != null ? String(obs.source_row) : "—"],
|
||||
["摘要", obs?.summary || event.reason || "—"],
|
||||
["确认状态", event.pairing === "paired" ? "已确认 · 双边一致" : event.status || "—"],
|
||||
];
|
||||
const dl = $("#tfEvFields");
|
||||
dl.replaceChildren(...fields.flatMap(([label, value]) => {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = label;
|
||||
const dd = document.createElement("dd");
|
||||
dd.className = "num";
|
||||
dd.textContent = value;
|
||||
return [dt, dd];
|
||||
}));
|
||||
$("[data-close-transfer-evidence]", drawer)?.focus();
|
||||
}
|
||||
|
||||
function closeTransferEvidence({ restoreFocus = true } = {}) {
|
||||
const drawer = $("#transferEvidenceDrawer");
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove("is-open");
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function buildTransfersExportUrl() {
|
||||
const detail = state.transfersDetail;
|
||||
const summary = state.transfersSummary;
|
||||
const params = new URLSearchParams();
|
||||
if (detail?.company_id) {
|
||||
params.set("counterparty_id", String(detail.company_id));
|
||||
const f = detail.filters || {};
|
||||
if (f.from) params.set("from", f.from);
|
||||
if (f.to) params.set("to", f.to);
|
||||
if (f.direction) params.set("direction", f.direction);
|
||||
} else if (summary?.window) {
|
||||
if (summary.window.start) params.set("from", summary.window.start);
|
||||
if (summary.window.end) params.set("to", summary.window.end);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return `/api/company/intercompany/export.csv${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
function initTransfers() {
|
||||
if (!$("[data-page='transfers']")) return;
|
||||
|
||||
$("#transfersRetryBtn")?.addEventListener("click", () => loadTransfersSummary());
|
||||
$("#transfersExportBtn")?.addEventListener("click", () => {
|
||||
window.location.href = buildTransfersExportUrl();
|
||||
});
|
||||
$("#transfersDetailExportBtn")?.addEventListener("click", () => {
|
||||
window.location.href = buildTransfersExportUrl();
|
||||
});
|
||||
$("#transfersBackBtn")?.addEventListener("click", () => {
|
||||
state.transfersDetail = null;
|
||||
showTransfersOverviewLayer();
|
||||
const title = $("#currentViewName");
|
||||
if (title) title.textContent = "转账往来";
|
||||
if (!state.transfersSummary) loadTransfersSummary();
|
||||
else renderTransfersOverview(state.transfersSummary);
|
||||
});
|
||||
$("#transfersCpBody")?.addEventListener("click", (event) => {
|
||||
const row = event.target.closest("tr[data-cp-id]");
|
||||
if (!row) return;
|
||||
openTransfersDetail(row.dataset.cpId, row.dataset.cpName);
|
||||
});
|
||||
$("#transfersFilterForm")?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
if (!state.transfersDetail) return;
|
||||
state.transfersDetail.filters = {
|
||||
from: $("#tfFilterFrom")?.value || "",
|
||||
to: $("#tfFilterTo")?.value || "",
|
||||
direction: $("#tfFilterDirection")?.value || "",
|
||||
state: $("#tfFilterState")?.value || "",
|
||||
};
|
||||
loadTransfersEvents({ reset: true });
|
||||
});
|
||||
const clearFilters = () => {
|
||||
if (!state.transfersDetail) return;
|
||||
const win = state.transfersSummary?.window || {};
|
||||
state.transfersDetail.filters = {
|
||||
from: win.start || "",
|
||||
to: win.end || "",
|
||||
direction: "",
|
||||
state: "",
|
||||
};
|
||||
$("#tfFilterFrom").value = state.transfersDetail.filters.from;
|
||||
$("#tfFilterTo").value = state.transfersDetail.filters.to;
|
||||
$("#tfFilterDirection").value = "";
|
||||
$("#tfFilterState").value = "";
|
||||
loadTransfersEvents({ reset: true });
|
||||
};
|
||||
$("#tfFilterReset")?.addEventListener("click", clearFilters);
|
||||
$("#tfEmptyClear")?.addEventListener("click", clearFilters);
|
||||
$("#transfersLoadMore")?.addEventListener("click", () => loadTransfersEvents({ reset: false }));
|
||||
$("#transfersEventBody")?.addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-transfer-evidence]");
|
||||
if (!btn) return;
|
||||
openTransferEvidence(btn.dataset.transferEvidence);
|
||||
});
|
||||
$$("[data-close-transfer-evidence]").forEach((btn) => btn.addEventListener("click", () => closeTransferEvidence()));
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && $("#transferEvidenceDrawer")?.classList.contains("is-open")) {
|
||||
event.stopPropagation();
|
||||
closeTransferEvidence();
|
||||
}
|
||||
});
|
||||
|
||||
// 工作台概览与角标:与页面共用 summary
|
||||
loadTransfersSummary();
|
||||
}
|
||||
|
||||
function initCompany() {
|
||||
renderCompanyManualRecords();
|
||||
loadCompanyAccounts();
|
||||
@@ -3059,6 +3631,9 @@ function initCompany() {
|
||||
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
|
||||
});
|
||||
|
||||
// ── 转账往来(方案 A)──
|
||||
initTransfers();
|
||||
|
||||
// ── 往来确认 + 工作台权威待办 ──
|
||||
initReconcile();
|
||||
(async () => {
|
||||
|
||||
+247
-15
@@ -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">
|
||||
@@ -141,29 +142,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">
|
||||
@@ -421,6 +422,214 @@
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -813,7 +1022,30 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="drawer" id="transferEvidenceDrawer" aria-label="银行原始流水" aria-hidden="true">
|
||||
<div class="drawer-head">
|
||||
<div>
|
||||
<span class="pill pill-info" id="tfEvTag">银行原始流水 · 只读</span>
|
||||
<h2 class="d-title" id="tfEvTitle">原始流水</h2>
|
||||
<p class="d-desc" id="tfEvDesc"></p>
|
||||
</div>
|
||||
<button type="button" class="icon-button" data-close-transfer-evidence aria-label="关闭" title="关闭">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<dl class="kv" id="tfEvFields"></dl>
|
||||
</div>
|
||||
<div class="drawer-tip">
|
||||
<strong>铁律提示</strong>
|
||||
银行原始流水永远只读。金额或归属有误时,须由管理员通过冲销/调整单留痕处理,本页不得直接改数。
|
||||
</div>
|
||||
<div class="drawer-foot">
|
||||
<button type="button" class="btn" data-close-transfer-evidence>关闭</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=10"></script>
|
||||
<script src="app.js?v=12"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user