Compare commits

...
Author SHA1 Message Date
b27016d71d HEL-177: 公司端按方案 A 实现转账往来四层页面
工作台概览小卡 + 侧栏入口;总览/对方汇总/明细/原始流水抽屉接入 HEL-175/176 接口;无期初时展示期间净变动;模块内用 info 品蓝。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 13:45:19 +00:00
总工 96309eb2cc HEL-177: merge HEL-174 confirm-status green into transfers page base 2026-08-27 13:17:28 +00:00
40f7a91a0f HEL-176: 公司端转账往来明细筛选与导出
新增 events 组合筛选 + keyset 分页,以及仅导出已确认明细的 CSV(含审计);
与 B-44 ledger /events 按查询参数分发,不改库表与确认语义。

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 12:56:20 +00:00
总工 642604f688 HEL-171 部署合并:公司端工作台待办同步(bf5754e) + 登录限流运维开关(551909a) 2026-08-27 12:28:04 +00:00
总工andmultica-agent 551909ad0c HEL-166: 增加 APP_LOGIN_RATE_LIMIT_DISABLED 运维开关(默认保持限流)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 11:48:00 +00:00
15 changed files with 3574 additions and 33 deletions
+2
View File
@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次, 公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。 首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。 - 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
内网测试环境可设 `APP_LOGIN_RATE_LIMIT_DISABLED=1` 暂时关闭该锁定
(默认未设置 = 保持限流,正式环境不得开启该变量)。
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖): 访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
+3
View File
@@ -26,6 +26,9 @@
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429, - 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
且在窗口内不再记录新尝试,行为确定、可测试。 且在窗口内不再记录新尝试,行为确定、可测试。
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。 - 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
- 运维开关:环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
时完全跳过限流检查,仅限内网测试环境临时使用;默认未设置,保持
「5 次失败锁 10 分钟」的生产安全策略不变。
## 角色与公司绑定 ## 角色与公司绑定
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""HEL-174: 360 / 820 / 1440 截图——待确认黄 vs 已完成绿。"""
from __future__ import annotations
import re
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = Path(__file__).resolve().parent.parent / "hel174-shots"
OUT.mkdir(parents=True, exist_ok=True)
VIEWPORTS = [
(1440, 900, "1440"),
(820, 900, "820"),
(360, 800, "360"),
]
def extract_fn(source: str, name: str) -> str:
marker = f"function {name}("
start = source.index(marker)
depth = 0
for i, ch in enumerate(source[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise RuntimeError(name)
def main() -> None:
app_js = (WEB / "app.js").read_text(encoding="utf-8")
inject = "\n".join(
[
"const state = {};",
"function $(sel, root) { return (root || document).querySelector(sel); }",
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
extract_fn(app_js, "formatWorkspaceAmount"),
extract_fn(app_js, "applyCompanyWorkspace"),
]
)
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{port}"
html = f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="{base}/design-system.css"/>
<title>HEL-174 往来确认状态色</title>
<style>
body {{ margin: 0; background: var(--bg); font-family: var(--font-body); color: var(--fg); }}
.shot-wrap {{ padding: 16px; max-width: 1100px; margin: 0 auto; }}
.shot-label {{ font-size: 13px; color: var(--muted); margin: 0 0 10px; }}
</style>
</head>
<body data-portal="company">
<div class="shot-wrap">
<p class="shot-label" id="shotLabel">状态预览</p>
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
<div class="card" id="workspaceTodos" style="margin-top:12px;">
<div class="card-head">
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">…</span></span>
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
</div>
<div id="workspaceTodoList"></div>
<div class="table-foot" id="workspaceTodoFoot"><span>…</span></div>
</div>
<div class="card" style="margin-top:12px;">
<div class="card-head">
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">…</span></span>
</div>
<div class="flow">
<a class="flow-step part" href="#upload">
<div class="fs-top"><span class="fs-idx">01</span><span class="fs-dot"></span><span class="fs-name">流水导入</span></div>
<div class="fs-state">部分完成</div>
</a>
<a class="flow-step done" href="#manual">
<div class="fs-top"><span class="fs-idx">02</span><span class="fs-dot"></span><span class="fs-name">手工补录</span></div>
<div class="fs-state">已完成</div>
</a>
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
<div class="fs-meta" id="workspaceConfirmMeta">…</div>
</a>
<div class="flow-step wait">
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
<div class="fs-state">等待集团</div>
</div>
</div>
</div>
<div class="notice warn" id="blocking-notice" style="margin-top:12px;">
<div>
<div class="n-title" id="notice-title">…</div>
<div class="n-body" id="notice-body">…</div>
</div>
</div>
<div style="margin-top:12px;">
<span class="pill pill-warn">待确认</span>
<span class="pill pill-success" style="margin-left:8px;">已确认</span>
</div>
<span class="tab-count" id="count-match" hidden>0</span>
<nav class="side-nav" hidden><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
</div>
</body></html>"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page()
for width, height, tag in VIEWPORTS:
page.set_viewport_size({"width": width, "height": height})
page.set_content(html, wait_until="domcontentloaded")
page.add_script_tag(content=inject)
page.evaluate(
"""() => {
document.getElementById('shotLabel').textContent = '待确认(应黄)';
applyCompanyWorkspace({
pending_unilateral: 3, pending_total: 3,
unilateral_events: [
{event_id:1, amount:'100000.00', currency:'CNY',
counterparty_company_name:'金牛贸易', effective_at:'2026-07-03'},
{event_id:2, amount:'200000.00', currency:'CNY',
counterparty_company_name:'金牛物流', effective_at:'2026-07-11'},
{event_id:3, amount:'300000.00', currency:'CNY',
counterparty_company_name:'金牛置业', effective_at:'2026-07-24'}
]
});
}"""
)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"hel174-pending-{tag}.png"), full_page=True)
page.evaluate(
"""() => {
document.getElementById('shotLabel').textContent = '已完成(应绿)';
applyCompanyWorkspace({
pending_unilateral: 0, pending_total: 0, unilateral_events: []
});
}"""
)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"hel174-done-{tag}.png"), full_page=True)
colors = page.evaluate(
"""() => {
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
const state = document.getElementById('workspaceConfirmState');
const pill = document.getElementById('workspacePendingStatus');
return {
stepClass: step.className,
stateText: state.textContent,
stateColor: getComputedStyle(state).color,
pillClass: pill.className,
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
}"""
)
assert "done" in colors["stepClass"], colors
assert "pill-success" in colors["pillClass"], colors
assert colors["stateText"] == "已完成", colors
assert not colors["overflowX"], colors
print(tag, colors)
browser.close()
httpd.shutdown()
print("shots:", sorted(p.name for p in OUT.glob("*.png")))
if __name__ == "__main__":
main()
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""HEL-177: 转账往来页 360/820/1440 冒烟截图(mock 接口,不依赖登录库)。"""
from __future__ import annotations
import json
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = ROOT / "hel177-shots"
OUT.mkdir(parents=True, exist_ok=True)
VIEWPORTS = [(1440, 900, "1440"), (820, 900, "820"), (360, 800, "360")]
SUMMARY = {
"status": "ok",
"own_company": {"id": 3, "name": "河南金牛煤业有限公司"},
"window": {
"start": "2026-01-01",
"end": "2026-08-20",
"has_opening": False,
"opening": None,
"ending": None,
},
"confirmed": {
"outflow_total": "123860000.00",
"outflow_count": 48,
"inflow_total": "184205000.00",
"inflow_count": 77,
"net_change": "-60345000.00",
"net_direction": "payable",
},
"pending": {"count": 3, "amount_total": "3200000.00"},
"counterparties": [
{
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"confirmed_outflow": "52000000.00",
"confirmed_inflow": "86000000.00",
"net": "-34000000.00",
"pending_count": 2,
"last_effective_at": "2026-08-18",
},
{
"company_id": 6,
"company_name": "河南金牛贸易有限公司",
"confirmed_outflow": "10000000.00",
"confirmed_inflow": "12605000.00",
"net": "-2605000.00",
"pending_count": 1,
"last_effective_at": "2026-08-10",
},
{
"company_id": 7,
"company_name": "河南金牛农业科技发展有限公司",
"confirmed_outflow": "61860000.00",
"confirmed_inflow": "85600000.00",
"net": "-23740000.00",
"pending_count": 0,
"last_effective_at": "2026-07-30",
},
],
}
# 修正 mock:按 HEL-169 净变动=转出-转入,正数应收
SUMMARY["confirmed"] = {
"outflow_total": "184205000.00",
"outflow_count": 80,
"inflow_total": "123860000.00",
"inflow_count": 48,
"net_change": "60345000.00",
"net_direction": "receivable",
}
SUMMARY["counterparties"] = [
{
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"confirmed_outflow": "86000000.00",
"confirmed_inflow": "52000000.00",
"net": "34000000.00",
"pending_count": 2,
"last_effective_at": "2026-08-18",
},
{
"company_id": 6,
"company_name": "河南金牛贸易有限公司",
"confirmed_outflow": "12605000.00",
"confirmed_inflow": "10000000.00",
"net": "2605000.00",
"pending_count": 1,
"last_effective_at": "2026-08-10",
},
{
"company_id": 7,
"company_name": "河南金牛农业科技发展有限公司",
"confirmed_outflow": "85600000.00",
"confirmed_inflow": "61860000.00",
"net": "23740000.00",
"pending_count": 0,
"last_effective_at": "2026-07-30",
},
]
EVENTS = {
"status": "ok",
"window": {"start": "2026-01-01", "end": "2026-08-20"},
"events": [
{
"event_id": 101,
"direction": "in",
"state": "confirmed",
"pairing": "paired",
"locked": False,
"amount": "12000000.00",
"currency": "CNY",
"effective_at": "2026-08-18",
"summary": "周转资金调拨",
"status": "matched",
},
{
"event_id": 102,
"direction": "out",
"state": "pending",
"pairing": "unilateral",
"locked": False,
"amount": "1500000.00",
"currency": "CNY",
"effective_at": "2026-08-12",
"summary": "工程款结算(待确认)",
"status": "unresolved",
},
],
"next_cursor": None,
"has_more": False,
}
DETAIL = {
"status": "ok",
"event": {
"event_id": 101,
"classification": "intercompany",
"pairing": "paired",
"status": "matched",
"amount": "12000000.00",
"currency": "CNY",
"effective_at": "2026-08-18",
"mode": "auto",
"reason": "双边匹配",
"counterparty": {
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"account_number_masked": "****8821",
},
"observations": [
{
"source_row_id": 1,
"role": "payee",
"source_row": 148,
"sheet_name": "流水",
"transaction_at": "2026-08-18 10:22:00",
"income": "12000000.00",
"expense": None,
"import_batch_id": 19,
"original_filename": "工行3305_202608.xls",
"own_account_masked": "****3305",
"counterparty_account_masked": "****8821",
"counterparty_name": "河南金牛置业有限公司",
"summary": "周转资金调拨",
"reference": "JS20260818-00317",
}
],
},
}
def main() -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{port}"
mock_js = f"""
window.__HEL177_MOCK__ = true;
const SUMMARY = {json.dumps(SUMMARY, ensure_ascii=False)};
const EVENTS = {json.dumps(EVENTS, ensure_ascii=False)};
const DETAIL = {json.dumps(DETAIL, ensure_ascii=False)};
const _fetch = window.fetch.bind(window);
window.fetch = async (input, init) => {{
const url = String(input);
if (url.includes('/api/me') || url.includes('/api/session')) {{
return new Response(JSON.stringify({{role:'company', company_id:3, username:'牛女士', company_name:'河南金牛煤业有限公司'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/workspace')) {{
return new Response(JSON.stringify({{status:'ok', pending_unilateral:0, pending_total:0, unilateral_events:[]}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/intercompany/summary')) {{
return new Response(JSON.stringify(SUMMARY), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/intercompany/events')) {{
return new Response(JSON.stringify(EVENTS), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/transfer-events/')) {{
return new Response(JSON.stringify(DETAIL), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/')) {{
return new Response(JSON.stringify({{status:'ok'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
return _fetch(input, init);
}};
"""
console_errors: list[str] = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.on("pageerror", lambda exc: console_errors.append(f"pageerror:{exc}"))
page.on("console", lambda msg: console_errors.append(f"console:{msg.type}:{msg.text}") if msg.type == "error" else None)
for width, height, label in VIEWPORTS:
page.set_viewport_size({"width": width, "height": height})
page.add_init_script(mock_js)
# bypass auth guard
page.add_init_script(
"""
const orig = window.fetch;
// initAuthGuard 读 /api/me;已在 mock 中处理
"""
)
page.goto(f"{base}/company.html", wait_until="domcontentloaded")
page.evaluate(
"""() => {
// 若鉴权把页面踢走,强制停留
if (!document.body || document.body.dataset.portal !== 'company') return;
}"""
)
# 等 summary 渲染
page.wait_for_timeout(600)
# 切到转账往来
page.evaluate("() => { if (typeof showView === 'function') showView('transfers'); }")
page.wait_for_selector("#transfersData:not([hidden])", timeout=5000)
page.wait_for_timeout(300)
overflow = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
assert not overflow, f"{label} overview overflow"
# 文案:期间净变动,无期末余额误用
net_title = page.inner_text("#tfStatNetTitle")
assert "期间净变动" in net_title, net_title
page.screenshot(path=str(OUT / f"overview-{label}.png"), full_page=True)
# 下钻明细
page.click("#transfersCpBody tr.clickable")
page.wait_for_selector("#transfersDetailLayer:not([hidden])", timeout=5000)
page.wait_for_timeout(300)
overflow2 = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
assert not overflow2, f"{label} detail overflow"
page.screenshot(path=str(OUT / f"detail-{label}.png"), full_page=True)
# 打开抽屉
page.click("[data-transfer-evidence]")
page.wait_for_selector("#transferEvidenceDrawer.is-open", timeout=5000)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"drawer-{label}.png"), full_page=True)
page.click("[data-close-transfer-evidence]")
browser.close()
hard = [e for e in console_errors if "Failed to load resource" not in e and "favicon" not in e]
print("shots:", list(OUT.glob("*.png")))
print("console_errors:", hard)
if hard:
raise SystemExit(1)
print("OK")
if __name__ == "__main__":
main()
+157 -4
View File
@@ -11,8 +11,8 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from bank_importer import ( from bank_importer import (
auth, dashboard, importing, ledger_events, manual_records, master_data, matching, auth, company_transfers, dashboard, importing, ledger_events, manual_records,
multipart, personal_transit, positions, settings, subjects, master_data, matching, multipart, personal_transit, positions, settings, subjects,
) )
from bank_importer.db import connect, migrate, utc_now 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))) self._handle_admin_intercompany_evidence(int(admin_evidence.group(1)))
return 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) # B-44 intercompany positions (company, own-company scope)
if path == "/api/company/intercompany/balances": if path == "/api/company/intercompany/balances":
self._handle_company_intercompany_balances(query) self._handle_company_intercompany_balances(query)
@@ -164,7 +172,12 @@ class AppHandler(SimpleHTTPRequestHandler):
self._handle_company_intercompany_pair(int(company_pair.group(1)), query) self._handle_company_intercompany_pair(int(company_pair.group(1)), query)
return return
if path == "/api/company/intercompany/events": 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 return
if path == "/api/company/manual-records": if path == "/api/company/manual-records":
self._handle_company_manual_records(query) 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, SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
r.own_account, r.own_name, r.counterparty_account, r.own_account, r.own_name, r.counterparty_account,
r.counterparty_name, r.summary, r.reference, 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 FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN import_batches b ON b.id = s.import_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 = ? WHERE r.id = ?
""", """,
(observation["source_row_id"],), (observation["source_row_id"],),
@@ -2446,9 +2461,16 @@ class AppHandler(SimpleHTTPRequestHandler):
"income": row["income"], "income": row["income"],
"expense": row["expense"], "expense": row["expense"],
"batch_company_id": row["batch_company_id"], "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"]) "own_account_masked": master_data.mask_account_number(row["own_account"])
if row["own_account"] if row["own_account"]
else None, else None,
"counterparty_account_masked": master_data.mask_account_number(
row["counterparty_account"]
)
if row["counterparty_account"]
else None,
"counterparty_name": row["counterparty_name"], "counterparty_name": row["counterparty_name"],
"summary": row["summary"], "summary": row["summary"],
"reference": row["reference"], "reference": row["reference"],
@@ -2858,6 +2880,137 @@ class AppHandler(SimpleHTTPRequestHandler):
return None, None return None, None
return user, user["company_id"] 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: def _handle_company_intercompany_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH) connection = connect(DB_PATH)
try: try:
+25 -12
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import hashlib import hashlib
import hmac import hmac
import os
import secrets import secrets
import sqlite3 import sqlite3
import string import string
@@ -28,6 +29,17 @@ RATE_LIMIT_WINDOW_MINUTES = 10
INITIAL_PASSWORD_LENGTH = 12 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: def hash_password(password: str) -> str:
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``.""" """Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
salt = secrets.token_bytes(16) salt = secrets.token_bytes(16)
@@ -146,18 +158,19 @@ def authenticate(
Every non-rate-limited attempt is recorded in ``login_attempts`` and Every non-rate-limited attempt is recorded in ``login_attempts`` and
``audit_log``; the password itself is never stored anywhere. ``audit_log``; the password itself is never stored anywhere.
""" """
window_start = ( if not RATE_LIMIT_DISABLED:
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES) window_start = (
).isoformat() datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
failures = connection.execute( ).isoformat()
""" failures = connection.execute(
SELECT COUNT(*) AS n FROM login_attempts """
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ? SELECT COUNT(*) AS n FROM login_attempts
""", WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
(username, ip, window_start), """,
).fetchone() (username, ip, window_start),
if failures["n"] >= RATE_LIMIT_MAX_FAILURES: ).fetchone()
return None, "rate_limited" if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
return None, "rate_limited"
user = connection.execute( user = connection.execute(
"SELECT * FROM users WHERE username = ?", (username,) "SELECT * FROM users WHERE username = ?", (username,)
+740
View File
@@ -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
+35
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
import hashlib import hashlib
import sqlite3 import sqlite3
import unittest import unittest
from unittest import mock
from bank_importer import auth from bank_importer import auth
from bank_importer.db import connect, migrate, utc_now from bank_importer.db import connect, migrate, utc_now
@@ -167,6 +168,40 @@ class AuthenticateTests(AuthTestCase):
self.assertIsNotNone(user) self.assertIsNotNone(user)
self.assertIsNone(reason) 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): class SessionTests(AuthTestCase):
def test_create_and_resolve_roundtrip(self) -> None: def test_create_and_resolve_roundtrip(self) -> None:
+221
View File
@@ -0,0 +1,221 @@
"""HEL-174: 公司端往来确认完成态用 success 绿,待确认保留 warn 黄。"""
from __future__ import annotations
import re
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover - 默认测试环境无浏览器依赖
sync_playwright = None
class ConfirmStatusSourceContractTests(unittest.TestCase):
"""不依赖浏览器:锁住完成态切绿 / 待确认仍黄的实现契约。"""
def test_app_js_toggles_success_green_when_pending_zero(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn('pill ${total ? "pill-warn" : "pill-success"}', js)
self.assertIn('flowStep.classList.toggle("doing", pending > 0)', js)
self.assertIn('flowStep.classList.toggle("done", pending === 0)', js)
self.assertIn('notice.classList.toggle("warn", pending > 0)', js)
self.assertIn('notice.classList.toggle("success", pending === 0)', js)
# 文案:完成=已完成,不把待确认一并改绿
self.assertRegex(js, r'status\.textContent = total \? `\$\{total\} 项待处理` : "已完成"')
self.assertRegex(js, r'flowState\.textContent = pending \? `待处理 \$\{pending\} 笔` : "已完成"')
def test_design_tokens_map_done_to_success_doing_to_warn(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn("--success:", css)
self.assertIn("--warn:", css)
self.assertIn(".flow-step.done .fs-dot { background: var(--success); }", css)
self.assertIn(".flow-step.done .fs-state { color: var(--success); }", css)
self.assertIn(".flow-step.doing .fs-dot { background: var(--warn);", css)
self.assertIn(".pill-success { background: var(--success-soft); color: var(--success); }", css)
self.assertIn(".pill-warn { background: var(--warn-soft);", css)
self.assertIn(".notice.success { background: var(--success-soft);", css)
self.assertIn(".notice.warn { background: var(--warn-soft);", css)
def test_company_html_exposes_flow_step_and_cache_bust(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
self.assertIn('id="workspaceConfirmState"', html)
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=12", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
def _extract_fn(source: str, name: str) -> str:
marker = f"function {name}("
start = source.index(marker)
depth = 0
for i, ch in enumerate(source[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError(f"未能截取 function {name}")
def _chromium_available() -> bool:
"""本机缺 libatk 等系统库时 chromium 无法启动。"""
try:
import ctypes.util
return bool(ctypes.util.find_library("atk-1.0"))
except Exception:
return False
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验")
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过浏览器色值校验")
class ConfirmStatusDomTests(unittest.TestCase):
"""真实浏览器:pending>0 为黄,pending=0 为绿。"""
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
app_js = (WEB / "app.js").read_text(encoding="utf-8")
cls.inject_js = "\n".join(
[
"const state = {};",
"function $(sel, root) { return (root || document).querySelector(sel); }",
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
_extract_fn(app_js, "formatWorkspaceAmount"),
_extract_fn(app_js, "applyCompanyWorkspace"),
]
)
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def _open_fixture(self, page):
page.goto(f"{self.base}/design-system.css", wait_until="domcontentloaded")
page.set_content(
f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body data-portal="company">
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
<div class="card" id="workspaceTodos">
<div class="card-head">
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">…</span></span>
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
</div>
<div id="workspaceTodoList"></div>
<div class="table-foot" id="workspaceTodoFoot"><span>…</span></div>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">账期流程<span class="sub" id="workspaceFlowSub">…</span></span>
</div>
<div class="flow">
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
<div class="fs-meta" id="workspaceConfirmMeta">…</div>
</a>
</div>
</div>
<div class="notice warn" id="blocking-notice">
<div class="n-title" id="notice-title">…</div>
<div class="n-body" id="notice-body">…</div>
</div>
<span class="tab-count" id="count-match">0</span>
<nav class="side-nav"><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
</body></html>""",
wait_until="domcontentloaded",
)
page.add_script_tag(content=self.inject_js)
page.wait_for_function("() => typeof applyCompanyWorkspace === 'function'")
@staticmethod
def _colors(page) -> dict:
return page.evaluate(
"""() => {
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
const state = document.getElementById('workspaceConfirmState');
const pill = document.getElementById('workspacePendingStatus');
const notice = document.getElementById('blocking-notice');
const cs = (el) => getComputedStyle(el);
return {
stepClass: step.className,
stateText: state.textContent,
stateColor: cs(state).color,
pillClass: pill.className,
pillColor: cs(pill).color,
pillText: pill.textContent,
noticeClass: notice.className,
};
}"""
)
def test_pending_stays_warn_completed_turns_success(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 900})
self._open_fixture(page)
page.evaluate(
"""() => applyCompanyWorkspace({
pending_unilateral: 2,
pending_total: 2,
unilateral_events: [
{event_id: 1, amount: '100.00', currency: 'CNY',
counterparty_company_name: '', effective_at: '2026-07-01'}
]
})"""
)
pending = self._colors(page)
self.assertIn("doing", pending["stepClass"])
self.assertNotIn("done", pending["stepClass"].split())
self.assertIn("pill-warn", pending["pillClass"])
self.assertNotIn("pill-success", pending["pillClass"])
self.assertIn("warn", pending["noticeClass"].split())
self.assertIn("待处理", pending["stateText"])
self.assertIn("待处理", pending["pillText"])
page.evaluate(
"""() => applyCompanyWorkspace({
pending_unilateral: 0,
pending_total: 0,
unilateral_events: []
})"""
)
done = self._colors(page)
self.assertIn("done", done["stepClass"])
self.assertNotIn("doing", done["stepClass"].split())
self.assertIn("pill-success", done["pillClass"])
self.assertNotIn("pill-warn", done["pillClass"])
self.assertIn("success", done["noticeClass"].split())
self.assertEqual("已完成", done["stateText"])
self.assertEqual("已完成", done["pillText"])
self.assertNotEqual(pending["stateColor"], done["stateColor"])
self.assertNotEqual(pending["pillColor"], done["pillColor"])
browser.close()
if __name__ == "__main__":
unittest.main()
+500
View File
@@ -0,0 +1,500 @@
"""HTTP tests for company intercompany events list + CSV export (HEL-176).
Covers combined filters, keyset pagination, empty state, lateral access,
forged company_id, ID guessing, export isolation and audit logging.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]
class CompanyIntercompanyEventsTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self._old_db_path = server.DB_PATH
self._old_storage = server.STORAGE_DIR
server.DB_PATH = self.db_path
server.STORAGE_DIR = self.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(self.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
self.port = self.httpd.server_address[1]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.admin = Client("127.0.0.1", self.port)
status, _, data = self.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = self.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
self.initial_passwords: dict[str, str] = {}
self.company_a = self._create_company("甲公司", "cashier-a")
self.company_b = self._create_company("乙公司", "cashier-b")
self.company_c = self._create_company("丙公司", "cashier-c")
self.cashier_a = self._login_company("cashier-a")
self.cashier_b = self._login_company("cashier-b")
self.cashier_c = self._login_company("cashier-c")
self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a)
self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b)
self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c)
def tearDown(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()
server.DB_PATH = self._old_db_path
server.STORAGE_DIR = self._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
def _create_company(self, name: str, username: str) -> int:
status, _, data = self.admin.post_json(
"/api/admin/companies", {"name": name, "username": username}
)
assert status == 200, data
self.initial_passwords[username] = as_json(data)["initial_password"]
return as_json(data)["company_id"]
def _login_company(self, username: str) -> Client:
client = Client("127.0.0.1", self.port)
initial = self.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
def _approve_account(self, company_id: int, number: str, client: Client) -> int:
status, _, data = client.post_json(
"/api/company/accounts",
{
"bank_name": "中信银行",
"account_type": "基本户",
"account_number": number,
"start_date": "2026-01-01",
},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{
"decision": "approve",
"reason": "测试启用",
"effective_from": "2026-01-01",
},
)
assert status == 200, data
return account_id
def _upload_and_confirm(self, client: Client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
def _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict:
self._upload_and_confirm(
self.cashier_a,
self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
)
status, _, data = self.admin.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
single = next(
e
for e in as_json(data)["events"]
if e["status"] == "internal_single" and e["amount"] == amount
)
status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}")
self.assertEqual(200, status, data)
revision = as_json(data)["event"]["revision"]
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{
"action": "assign_participant",
"reason": "函证确认",
"expected_revision": revision,
"request_key": f"lock-{amount}-{at}",
"participant": {"role": "payee", "company_id": self.company_b},
},
)
self.assertEqual(200, status, data)
return as_json(data)["decision"]
def _seed_mixed(self) -> None:
"""Paired A→B 100, B→A 40, locked A→B 25, pending A→B 7, B→C 200."""
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")],
)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")],
)
self._lock_single("25.00", "2026-02-01 10:00:00")
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")],
)
self._upload_and_confirm(
self.cashier_c, self.company_c,
[incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")],
)
def _events(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"):
status, _, data = client.get(f"/api/company/intercompany/events?{query}")
return status, as_json(data) if data else {}
def _export(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"):
return client.get(f"/api/company/intercompany/export.csv?{query}")
# ------------------------------------------------------------------
# Auth / parameter guards
# ------------------------------------------------------------------
def test_requires_company_role(self) -> None:
status, payload = self._events(self.admin)
self.assertEqual(403, status, payload)
def test_rejects_forged_company_id_on_events_and_export(self) -> None:
status, payload = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&company_id={self.company_b}",
)
self.assertEqual(400, status, payload)
status, _, data = self._export(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&company_id={self.company_a}",
)
self.assertEqual(400, status, data)
def test_rejects_bad_filters(self) -> None:
status, payload = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=sideways"
)
self.assertEqual(400, status, payload)
status, payload = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=maybe"
)
self.assertEqual(400, status, payload)
status, payload = self._events(
self.cashier_a, "from=2026-13-40&to=2026-12-31"
)
self.assertEqual(400, status, payload)
def test_empty_window(self) -> None:
status, payload = self._events(self.cashier_a)
self.assertEqual(200, status, payload)
self.assertEqual([], payload["events"])
self.assertFalse(payload["has_more"])
self.assertIsNone(payload["next_cursor"])
# ------------------------------------------------------------------
# Combined filters + confirmed/pending separation
# ------------------------------------------------------------------
def test_combined_filters_and_state_split(self) -> None:
self._seed_mixed()
status, payload = self._events(self.cashier_a)
self.assertEqual(200, status, payload)
events = payload["events"]
# A sees: out 100, in 40, locked out 25, pending out 7 — not B↔C 200
self.assertEqual(4, len(events))
amounts = {e["amount"] for e in events}
self.assertEqual({"100.00", "40.00", "25.00", "7.00"}, amounts)
for event in events:
self.assertNotEqual(self.company_c, event["counterparty_company_id"])
self.assertIn(event["state"], ("confirmed", "pending"))
self.assertIn(event["direction"], ("out", "in"))
status, confirmed = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=confirmed"
)
self.assertEqual(200, status, confirmed)
self.assertEqual(3, len(confirmed["events"]))
self.assertTrue(all(e["state"] == "confirmed" for e in confirmed["events"]))
self.assertNotIn("7.00", {e["amount"] for e in confirmed["events"]})
status, pending = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending"
)
self.assertEqual(200, status, pending)
self.assertEqual(1, len(pending["events"]))
self.assertEqual("7.00", pending["events"][0]["amount"])
self.assertEqual("pending", pending["events"][0]["state"])
status, outs = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=out&state=confirmed"
)
self.assertEqual(200, status, outs)
self.assertEqual({"100.00", "25.00"}, {e["amount"] for e in outs["events"]})
self.assertTrue(all(e["direction"] == "out" for e in outs["events"]))
status, by_cp = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_b}&state=confirmed",
)
self.assertEqual(200, status, by_cp)
self.assertEqual(3, len(by_cp["events"]))
# Date window excludes Feb locked/pending
status, jan = self._events(
self.cashier_a, "from=2026-01-01&to=2026-01-31&state=confirmed"
)
self.assertEqual(200, status, jan)
self.assertEqual({"100.00", "40.00"}, {e["amount"] for e in jan["events"]})
def test_keyset_pagination_no_dup_no_gap(self) -> None:
# Three confirmed A→B outs on distinct days
for i, amount in enumerate(("11.00", "12.00", "13.00", "14.00", "15.00")):
day = 5 + i
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, f"2026-01-{day:02d} 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, amount, f"2026-01-{day:02d} 11:00:00")],
)
status, page1 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out&limit=2",
)
self.assertEqual(200, status, page1)
self.assertEqual(2, len(page1["events"]))
self.assertTrue(page1["has_more"])
self.assertIsNotNone(page1["next_cursor"])
status, page2 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out"
f"&limit=2&cursor={page1['next_cursor']}",
)
self.assertEqual(200, status, page2)
self.assertEqual(2, len(page2["events"]))
self.assertTrue(page2["has_more"])
status, page3 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out"
f"&limit=2&cursor={page2['next_cursor']}",
)
self.assertEqual(200, status, page3)
self.assertEqual(1, len(page3["events"]))
self.assertFalse(page3["has_more"])
self.assertIsNone(page3["next_cursor"])
ids = [e["event_id"] for e in page1["events"] + page2["events"] + page3["events"]]
self.assertEqual(5, len(ids))
self.assertEqual(len(ids), len(set(ids)))
# Descending by effective_at then event_id
amounts = [e["amount"] for e in page1["events"] + page2["events"] + page3["events"]]
self.assertEqual(["15.00", "14.00", "13.00", "12.00", "11.00"], amounts)
# ------------------------------------------------------------------
# Isolation / ID guess / detail reuse
# ------------------------------------------------------------------
def test_lateral_isolation_and_id_guess_404(self) -> None:
self._seed_mixed()
status, payload_a = self._events(self.cashier_a)
self.assertEqual(200, status, payload_a)
a_ids = {e["event_id"] for e in payload_a["events"]}
status, payload_c = self._events(self.cashier_c)
self.assertEqual(200, status, payload_c)
# C only participates in B↔C 200
self.assertTrue(payload_c["events"])
for event in payload_c["events"]:
self.assertEqual("200.00", event["amount"])
self.assertNotIn(event["event_id"], a_ids)
# C guessing A's event id via transfer-events detail → 404
a_event_id = next(iter(a_ids))
status, _, data = self.cashier_c.get(
f"/api/company/transfer-events/{a_event_id}"
)
self.assertEqual(404, status, data)
# A can open own event; counterparty account masked; only own observations
status, _, data = self.cashier_a.get(
f"/api/company/transfer-events/{a_event_id}"
)
self.assertEqual(200, status, data)
detail = as_json(data)["event"]
if detail.get("counterparty") and "account_number_masked" in detail["counterparty"]:
masked = detail["counterparty"]["account_number_masked"]
self.assertTrue(str(masked).startswith("****"))
self.assertNotIn(ACCOUNT_B, masked)
for obs in detail["observations"]:
self.assertEqual(self.company_a, obs["batch_company_id"])
self.assertNotIn(ACCOUNT_B, json.dumps(obs, ensure_ascii=False))
# Filtering by counterparty C still cannot leak B↔C into A's list
status, filtered = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_c}",
)
self.assertEqual(200, status, filtered)
self.assertEqual([], filtered["events"])
def test_b44_events_path_still_works_with_cutoff(self) -> None:
# Without HEL-176 discriminators, /events stays on B-44 ledger list.
status, _, data = self.cashier_a.get(
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertIn("items", payload)
self.assertNotIn("events", payload)
# ------------------------------------------------------------------
# Export: confirmed only + audit + isolation
# ------------------------------------------------------------------
def test_export_confirmed_only_isolated_and_audited(self) -> None:
self._seed_mixed()
status, headers, data = self._export(self.cashier_a)
self.assertEqual(200, status, data)
self.assertEqual("text/csv; charset=utf-8", headers.get("content-type"))
text = data.decode("utf-8-sig")
lines = [line for line in text.splitlines() if line]
self.assertGreaterEqual(len(lines), 2)
body = "\n".join(lines[1:])
self.assertIn("100.00", body)
self.assertIn("40.00", body)
self.assertIn("25.00", body)
self.assertNotIn("7.00", body) # pending excluded
self.assertNotIn("200.00", body) # B↔C excluded
# Explicit pending state rejected
status, _, data = self._export(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending"
)
self.assertEqual(400, status, data)
# C export must not contain A's amounts
status, _, data = self._export(self.cashier_c)
self.assertEqual(200, status, data)
text_c = data.decode("utf-8-sig")
self.assertNotIn("100.00", text_c)
self.assertNotIn("25.00", text_c)
self.assertIn("200.00", text_c)
status, _, data = self.admin.get("/api/admin/audit-log?limit=50")
self.assertEqual(200, status, data)
actions = [row["action"] for row in as_json(data)["entries"]]
self.assertIn("export_intercompany_csv", actions)
export_rows = [
row
for row in as_json(data)["entries"]
if row["action"] == "export_intercompany_csv"
]
self.assertTrue(export_rows)
self.assertTrue(
any(f"company:{self.company_a}" == row.get("target") for row in export_rows)
)
self.assertTrue(any("state:confirmed" in (row.get("detail") or "") for row in export_rows))
if __name__ == "__main__":
unittest.main()
+362
View File
@@ -0,0 +1,362 @@
"""HTTP tests for GET /api/company/intercompany/summary (HEL-175).
Covers session-scoped company_id, forged company_id rejection, confirmed vs
pending separation, Decimal net math, dual-company isolation and empty data.
"""
from __future__ import annotations
import io
import json
import os
from decimal import Decimal
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]
class CompanyIntercompanySummaryTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self._old_db_path = server.DB_PATH
self._old_storage = server.STORAGE_DIR
server.DB_PATH = self.db_path
server.STORAGE_DIR = self.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(self.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
self.port = self.httpd.server_address[1]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.admin = Client("127.0.0.1", self.port)
status, _, data = self.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = self.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
self.initial_passwords: dict[str, str] = {}
self.company_a = self._create_company("甲公司", "cashier-a")
self.company_b = self._create_company("乙公司", "cashier-b")
self.company_c = self._create_company("丙公司", "cashier-c")
self.cashier_a = self._login_company("cashier-a")
self.cashier_b = self._login_company("cashier-b")
self.cashier_c = self._login_company("cashier-c")
self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a)
self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b)
self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c)
def tearDown(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()
server.DB_PATH = self._old_db_path
server.STORAGE_DIR = self._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
def _create_company(self, name: str, username: str) -> int:
status, _, data = self.admin.post_json(
"/api/admin/companies", {"name": name, "username": username}
)
assert status == 200, data
self.initial_passwords[username] = as_json(data)["initial_password"]
return as_json(data)["company_id"]
def _login_company(self, username: str) -> Client:
client = Client("127.0.0.1", self.port)
initial = self.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
def _approve_account(self, company_id: int, number: str, client: Client) -> int:
status, _, data = client.post_json(
"/api/company/accounts",
{
"bank_name": "中信银行",
"account_type": "基本户",
"account_number": number,
"start_date": "2026-01-01",
},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{
"decision": "approve",
"reason": "测试启用",
"effective_from": "2026-01-01",
},
)
assert status == 200, data
return account_id
def _upload_and_confirm(self, client: Client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
def _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict:
"""A-side only upload → admin locks as intercompany single."""
self._upload_and_confirm(
self.cashier_a,
self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
)
status, _, data = self.admin.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
single = next(
e
for e in as_json(data)["events"]
if e["status"] == "internal_single" and e["amount"] == amount
)
status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}")
self.assertEqual(200, status, data)
revision = as_json(data)["event"]["revision"]
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{
"action": "assign_participant",
"reason": "函证确认",
"expected_revision": revision,
"request_key": f"lock-{amount}-{at}",
"participant": {"role": "payee", "company_id": self.company_b},
},
)
self.assertEqual(200, status, data)
return as_json(data)["decision"]
def _summary(self, client: Client, query: str = "as_of=2026-12-31"):
status, _, data = client.get(f"/api/company/intercompany/summary?{query}")
return status, as_json(data) if data else {}
# ------------------------------------------------------------------
# Auth / parameter guards
# ------------------------------------------------------------------
def test_requires_company_role(self) -> None:
status, payload = self._summary(self.admin)
self.assertEqual(403, status, payload)
def test_rejects_forged_company_id(self) -> None:
status, payload = self._summary(
self.cashier_a, f"as_of=2026-12-31&company_id={self.company_b}"
)
self.assertEqual(400, status, payload)
self.assertIn("company_id", payload.get("message", ""))
def test_rejects_own_company_id_param(self) -> None:
# Even matching the session company is forbidden.
status, payload = self._summary(
self.cashier_a, f"as_of=2026-12-31&company_id={self.company_a}"
)
self.assertEqual(400, status, payload)
def test_rejects_bad_as_of(self) -> None:
status, payload = self._summary(self.cashier_a, "as_of=2026-13-40")
self.assertEqual(400, status, payload)
def test_anonymous_is_unauthorized(self) -> None:
anon = Client("127.0.0.1", self.port)
status, _, data = anon.get("/api/company/intercompany/summary?as_of=2026-12-31")
self.assertIn(status, (401, 403))
# ------------------------------------------------------------------
# Empty / confirmed math / pending isolation
# ------------------------------------------------------------------
def test_empty_window_returns_zeros(self) -> None:
status, payload = self._summary(self.cashier_a)
self.assertEqual(200, status, payload)
self.assertEqual(self.company_a, payload["own_company"]["id"])
self.assertFalse(payload["window"]["has_opening"])
self.assertIsNone(payload["window"]["opening"])
self.assertEqual("0.00", payload["confirmed"]["outflow_total"])
self.assertEqual("0.00", payload["confirmed"]["inflow_total"])
self.assertEqual("0.00", payload["confirmed"]["net_change"])
self.assertEqual(0, payload["pending"]["count"])
self.assertEqual([], payload["counterparties"])
# Amounts must be strings, never floats.
self.assertIsInstance(payload["confirmed"]["net_change"], str)
self.assertNotIsInstance(payload["confirmed"]["net_change"], float)
def test_paired_locked_pending_math_and_isolation(self) -> None:
# Paired A→B 100 + B→A 40 → A net outflow 60
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")],
)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")],
)
# Locked single A→B 25 (confirmed)
self._lock_single("25.00", "2026-02-01 10:00:00")
# Pending unilateral A→B 7 (internal_single, not confirmed)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")],
)
# B↔C paired 200 — must not appear in A's summary
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")],
)
self._upload_and_confirm(
self.cashier_c, self.company_c,
[incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")],
)
status, payload = self._summary(self.cashier_a)
self.assertEqual(200, status, payload)
confirmed = payload["confirmed"]
self.assertEqual("125.00", confirmed["outflow_total"]) # 100 + 25
self.assertEqual(2, confirmed["outflow_count"])
self.assertEqual("40.00", confirmed["inflow_total"])
self.assertEqual(1, confirmed["inflow_count"])
self.assertEqual("85.00", confirmed["net_change"]) # 125 - 40
self.assertEqual("receivable", confirmed["net_direction"])
# Pending tip only — never folded into confirmed totals
self.assertEqual(1, payload["pending"]["count"])
self.assertEqual("7.00", payload["pending"]["amount_total"])
# Decimal identity: net = outflow - inflow, no float drift
net = Decimal(confirmed["net_change"])
self.assertEqual(
Decimal(confirmed["outflow_total"]) - Decimal(confirmed["inflow_total"]),
net,
)
counterparties = {row["company_id"]: row for row in payload["counterparties"]}
self.assertIn(self.company_b, counterparties)
self.assertNotIn(self.company_c, counterparties)
row_b = counterparties[self.company_b]
self.assertEqual("125.00", row_b["confirmed_outflow"])
self.assertEqual("40.00", row_b["confirmed_inflow"])
self.assertEqual("85.00", row_b["net"])
self.assertEqual(1, row_b["pending_count"])
# B must not see C-only? B sees C; A must not see B's C totals via forgery
status_b, payload_b = self._summary(self.cashier_b)
self.assertEqual(200, status_b, payload_b)
cps_b = {row["company_id"] for row in payload_b["counterparties"]}
self.assertIn(self.company_c, cps_b)
# A's view still excludes C
self.assertNotIn(self.company_c, counterparties)
# Same event counted once: eligible event count equals outflow+inflow counts
self.assertEqual(
confirmed["outflow_count"] + confirmed["inflow_count"],
3,
)
def test_company_b_cannot_see_a_only_pending(self) -> None:
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "9.00", "2026-04-01 10:00:00")],
)
status_a, payload_a = self._summary(self.cashier_a)
self.assertEqual(200, status_a, payload_a)
self.assertEqual(1, payload_a["pending"]["count"])
status_c, payload_c = self._summary(self.cashier_c)
self.assertEqual(200, status_c, payload_c)
self.assertEqual(0, payload_c["pending"]["count"])
self.assertEqual("0.00", payload_c["confirmed"]["outflow_total"])
self.assertEqual([], payload_c["counterparties"])
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
"""HEL-177: 公司端转账往来页面(方案 A)结构与接口契约。"""
from __future__ import annotations
import re
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
class TransfersPageSourceContractTests(unittest.TestCase):
def test_company_html_has_nav_overview_and_layers(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
self.assertIn('data-view="transfers"', html)
self.assertIn("转账往来", html)
self.assertIn('id="workspaceTransfersCard"', html)
self.assertIn("转账往来概览", html)
self.assertIn('data-page="transfers"', html)
self.assertIn('id="transfersOverviewLayer"', html)
self.assertIn('id="transfersDetailLayer"', html)
self.assertIn('id="transferEvidenceDrawer"', html)
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=6", html)
self.assertIn("app.js?v=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
View File
@@ -4,7 +4,7 @@ const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)]
const portal = document.body.dataset.portal || "entry"; const portal = document.body.dataset.portal || "entry";
const viewNames = portal === "admin" const viewNames = portal === "admin"
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" } ? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" }; : { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", transfers: "转账往来", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
const storageKeys = { const storageKeys = {
manual: "ledger-demo-manual-records", manual: "ledger-demo-manual-records",
@@ -177,11 +177,27 @@ function showView(view) {
else item.removeAttribute("aria-current"); else item.removeAttribute("aria-current");
}); });
const title = $("#currentViewName"); 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 }); closeNavigation({ restoreFocus: navigationWasOpen });
const activeView = $(`.app-view[data-page="${view}"]`); const activeView = $(`.app-view[data-page="${view}"]`);
requestAnimationFrame(() => animateView(activeView)); requestAnimationFrame(() => animateView(activeView));
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" }); 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 仅用于演示总览/工作台事项 // 原型占位,非本司真待办/真断档数据:detailContent 仅用于演示总览/工作台事项
@@ -2619,6 +2635,18 @@ function applyCompanyWorkspace(payload) {
? `单边流水 ${pending} 笔待确认` ? `单边流水 ${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"); const countMatch = $("#count-match");
if (countMatch) countMatch.textContent = String(pending); if (countMatch) countMatch.textContent = String(pending);
@@ -2922,6 +2950,550 @@ function openAccountDetail(account) {
$("#modal-account-detail")?.classList.add("open"); $("#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, "&quot;")}">
<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() { function initCompany() {
renderCompanyManualRecords(); renderCompanyManualRecords();
loadCompanyAccounts(); loadCompanyAccounts();
@@ -3059,6 +3631,9 @@ function initCompany() {
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success"); showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
}); });
// ── 转账往来(方案 A)──
initTransfers();
// ── 往来确认 + 工作台权威待办 ── // ── 往来确认 + 工作台权威待办 ──
initReconcile(); initReconcile();
(async () => { (async () => {
+247 -15
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团公司业务端" /> <meta name="description" content="金牛集团公司业务端" />
<title>公司业务端 · 金牛集团</title> <title>公司业务端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=5" /> <link rel="stylesheet" href="design-system.css?v=6" />
</head> </head>
<body data-portal="company"> <body data-portal="company">
<a class="skip-link" href="#main-content">跳到主要内容</a> <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="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="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="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> <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> <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> <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">
<div class="card-head"> <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> <span class="pill pill-warn">结账日顺延至 08-29</span>
</div> </div>
<div class="flow"> <div class="flow">
@@ -141,29 +142,29 @@
</div> </div>
<div class="stack"> <div class="stack">
<div class="card"> <div class="card" id="workspaceTransfersCard">
<div class="card-head"> <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>
<div class="mini-stats"> <div class="mini-stats" id="workspaceTransfersStats">
<div class="mini-stat"> <div class="mini-stat">
<div class="ms-label">借方合计</div> <div class="ms-label">转入合计</div>
<div class="ms-value">18,420.50<span class="unit">万元</span></div> <div class="ms-value pos" id="wsTfIn"></div>
</div> </div>
<div class="mini-stat"> <div class="mini-stat">
<div class="ms-label">贷方合计</div> <div class="ms-label">转出合计</div>
<div class="ms-value">12,386.00<span class="unit">万元</span></div> <div class="ms-value neg" id="wsTfOut"></div>
</div> </div>
<div class="mini-stat"> <div class="mini-stat">
<div class="ms-label">明细笔数</div> <div class="ms-label" id="wsTfNetLabel">期间净变动</div>
<div class="ms-value">128<span class="unit"></span></div> <div class="ms-value" id="wsTfNet"></div>
</div> </div>
<div class="mini-stat"> <div class="mini-stat">
<div class="ms-label">期末净往来 · 应收方向</div> <div class="ms-label">待确认 · 不计入合计</div>
<div class="ms-value pos">+6,034.50<span class="unit">万元</span></div> <div class="ms-value" id="wsTfPending" style="color: var(--warn);"></div>
</div> </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>
<div class="card"> <div class="card">
@@ -421,6 +422,214 @@
</div> </div>
</section> </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"> <section class="app-view" data-page="reconcile">
<div class="page-head"> <div class="page-head">
<div> <div>
@@ -813,7 +1022,30 @@
</form> </form>
</div> </div>
</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> <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> </body>
</html> </html>
+95
View File
@@ -988,3 +988,98 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans
/* ─── 提示条补充:成功态 ───────────────────────────────────────── */ /* ─── 提示条补充:成功态 ───────────────────────────────────────── */
.notice.success { background: var(--success-soft); border-color: color-mix(in oklch, var(--success) 30%, transparent); } .notice.success { 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); } .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); }
}