HEL-177: 公司端按方案 A 实现转账往来四层页面
工作台概览小卡 + 侧栏入口;总览/对方汇总/明细/原始流水抽屉接入 HEL-175/176 接口;无期初时展示期间净变动;模块内用 info 品蓝。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
96309eb2cc
commit
b27016d71d
@@ -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()
|
||||||
@@ -2439,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"],),
|
||||||
@@ -2459,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"],
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
|
|||||||
self.assertIn('id="workspacePendingStatus"', html)
|
self.assertIn('id="workspacePendingStatus"', html)
|
||||||
self.assertIn('id="workspaceFlowSub"', html)
|
self.assertIn('id="workspaceFlowSub"', html)
|
||||||
self.assertIn('data-view-link="reconcile"', html)
|
self.assertIn('data-view-link="reconcile"', html)
|
||||||
self.assertIn("app.js?v=11", html)
|
self.assertIn("app.js?v=12", html)
|
||||||
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
|
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
|
||||||
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
|
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
|
||||||
|
|
||||||
@@ -69,7 +69,18 @@ def _extract_fn(source: str, name: str) -> str:
|
|||||||
raise AssertionError(f"未能截取 function {name}")
|
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(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验")
|
||||||
|
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过浏览器色值校验")
|
||||||
class ConfirmStatusDomTests(unittest.TestCase):
|
class ConfirmStatusDomTests(unittest.TestCase):
|
||||||
"""真实浏览器:pending>0 为黄,pending=0 为绿。"""
|
"""真实浏览器:pending>0 为黄,pending=0 为绿。"""
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+565
-2
@@ -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 仅用于演示总览/工作台事项
|
||||||
@@ -2934,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, """)}">
|
||||||
|
<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();
|
||||||
@@ -3071,6 +3631,9 @@ function initCompany() {
|
|||||||
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
|
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 转账往来(方案 A)──
|
||||||
|
initTransfers();
|
||||||
|
|
||||||
// ── 往来确认 + 工作台权威待办 ──
|
// ── 往来确认 + 工作台权威待办 ──
|
||||||
initReconcile();
|
initReconcile();
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
+246
-14
@@ -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>
|
||||||
@@ -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=11"></script>
|
<script src="app.js?v=12"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -988,3 +988,98 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans
|
|||||||
/* ─── 提示条补充:成功态 ───────────────────────────────────────── */
|
/* ─── 提示条补充:成功态 ───────────────────────────────────────── */
|
||||||
.notice.success { background: var(--success-soft); border-color: color-mix(in oklch, var(--success) 30%, transparent); }
|
.notice.success { 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); }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user