Compare commits

..
Author SHA1 Message Date
总工 642604f688 HEL-171 部署合并:公司端工作台待办同步(bf5754e) + 登录限流运维开关(551909a) 2026-08-27 12:28:04 +00:00
bf5754ee09 HEL-168: 公司端工作台待办按权威单边集合同步
确认成功后重拉 /api/company/workspace,去确认数字与本月待办共用同一口径;
新增公司端确认接口与 3→2→1→0 集成测试,失败/重复确认不误减。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 12:10:38 +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
9 changed files with 836 additions and 263 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 分钟」的生产安全策略不变。
## 角色与公司绑定 ## 角色与公司绑定
+129 -7
View File
@@ -115,6 +115,9 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/company/match-exceptions": if path == "/api/company/match-exceptions":
self._handle_company_match_exceptions(query) self._handle_company_match_exceptions(query)
return return
if path == "/api/company/workspace":
self._handle_company_workspace()
return
company_event_match = re.fullmatch(r"/api/company/transfer-events/(\d+)", path) company_event_match = re.fullmatch(r"/api/company/transfer-events/(\d+)", path)
if company_event_match: if company_event_match:
self._handle_company_transfer_event_detail(int(company_event_match.group(1))) self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
@@ -277,6 +280,12 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/company/manual-records": if path == "/api/company/manual-records":
self._handle_company_manual_records_submit() self._handle_company_manual_records_submit()
return return
company_confirm = re.fullmatch(
r"/api/company/transfer-events/(\d+)/confirm", path
)
if company_confirm:
self._handle_company_transfer_confirm(int(company_confirm.group(1)))
return
self._send_json(404, {"status": "error", "message": "接口不存在。"}) self._send_json(404, {"status": "error", "message": "接口不存在。"})
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -2195,6 +2204,8 @@ class AppHandler(SimpleHTTPRequestHandler):
) )
return { return {
"event_id": row["event_id"], "event_id": row["event_id"],
"decision_id": row["decision_id"],
"revision": row["revision"],
"classification": row["classification"], "classification": row["classification"],
"pairing": row["pairing"], "pairing": row["pairing"],
"status": matching.exposed_status(row), "status": matching.exposed_status(row),
@@ -2209,6 +2220,20 @@ class AppHandler(SimpleHTTPRequestHandler):
"evidence_count": row["evidence_count"], "evidence_count": row["evidence_count"],
} }
def _handle_company_workspace(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return
payload = matching.company_workspace_payload(connection, user["company_id"])
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_company_match_exceptions(self, query: dict[str, list[str]]) -> None: def _handle_company_match_exceptions(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH) connection = connect(DB_PATH)
try: try:
@@ -2218,13 +2243,7 @@ class AppHandler(SimpleHTTPRequestHandler):
if user["role"] != "company": if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"}) self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return return
params: list[object] = [user["company_id"], user["company_id"]] rows = matching.company_pending_unilaterals(connection, user["company_id"])
rows = connection.execute(
self._event_base_sql(True)
+ "AND d.classification IN ('unresolved', 'needs_review') "
"ORDER BY d.id DESC LIMIT 500",
params,
).fetchall()
items = [ items = [
self._company_event_list_item(row, user["company_id"]) for row in rows self._company_event_list_item(row, user["company_id"]) for row in rows
] ]
@@ -2232,6 +2251,109 @@ class AppHandler(SimpleHTTPRequestHandler):
finally: finally:
connection.close() connection.close()
def _handle_company_transfer_confirm(self, event_id: int) -> None:
"""Company confirms a pending unilateral using existing assign_participant rules."""
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return
company_id = int(user["company_id"])
data = self._read_json_body()
if data is None:
return
request_key = str(data.get("request_key") or "").strip() or None
# Idempotent replay: same request_key returns current workspace without re-decrement.
if request_key:
existing = connection.execute(
"""
SELECT id, actor_username
FROM transfer_match_decisions
WHERE idempotency_key = ? AND event_id = ?
""",
(request_key, event_id),
).fetchone()
if existing is not None:
if existing["actor_username"] != user["username"]:
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
return
workspace = matching.company_workspace_payload(connection, company_id)
payload = matching._decision_payload(connection, event_id, existing["id"])
self._send_json(
200,
{"status": "ok", "decision": payload, "workspace": workspace},
)
return
pending = matching.company_pending_unilaterals(connection, company_id)
target = next((row for row in pending if int(row["event_id"]) == event_id), None)
if target is None:
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
return
try:
expected_revision = (
int(data["expected_revision"])
if data.get("expected_revision") is not None
else None
)
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "expected_revision 参数无效。"})
return
try:
counterparty_company_id = int(data["counterparty_company_id"])
except (KeyError, TypeError, ValueError):
self._send_json(
400, {"status": "error", "message": "counterparty_company_id 必须是有效的公司 id。"}
)
return
if counterparty_company_id == company_id:
self._send_json(400, {"status": "error", "message": "对方公司不能是本公司。"})
return
# Own side is already on the decision; assign the opposite role.
own_is_payer = target["payer_company_id"] == company_id
role = "payee" if own_is_payer else "payer"
if target["payer_company_id"] is None and target["payee_company_id"] is None:
self._send_json(400, {"status": "error", "message": "单边流水缺少本方参与方,无法确认。"})
return
if not own_is_payer and target["payee_company_id"] != company_id:
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
return
reason = str(data.get("reason") or "").strip() or "公司端确认单边流水"
try:
payload = matching.apply_manual_decision(
connection,
event_id,
"assign_participant",
reason=reason,
expected_revision=expected_revision,
request_key=request_key,
actor=user,
participant={
"role": role,
"company_id": counterparty_company_id,
},
)
except matching.MatchConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except matching.MatchInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
try:
ledger_events.reconcile_bank_events(connection, actor=user)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
return
workspace = matching.company_workspace_payload(connection, company_id)
self._send_json(
200,
{"status": "ok", "decision": payload, "workspace": workspace},
)
finally:
connection.close()
def _handle_company_transfer_event_detail(self, event_id: int) -> None: def _handle_company_transfer_event_detail(self, event_id: int) -> 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,)
+81
View File
@@ -1563,6 +1563,87 @@ def exposed_status(decision: sqlite3.Row | dict) -> str:
return "unresolved" return "unresolved"
def company_pending_unilaterals(
connection: sqlite3.Connection, company_id: int
) -> list[sqlite3.Row]:
"""Authoritative pending unilateral queue for a company workspace.
Same set as company match-exceptions: active events whose current decision
is ``unresolved`` / ``needs_review`` and the company participates.
Parentheses on the company filter avoid OR/AND precedence bugs.
"""
return connection.execute(
"""
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,
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
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 d.classification IN ('unresolved', 'needs_review')
AND (payer.company_id = ? OR payee.company_id = ?)
ORDER BY d.id DESC
""",
(company_id, company_id),
).fetchall()
def company_workspace_payload(
connection: sqlite3.Connection, company_id: int
) -> dict[str, object]:
"""Workspace counts + list share one authoritative unilateral queue."""
rows = company_pending_unilaterals(connection, company_id)
events = [
{
"event_id": row["event_id"],
"decision_id": row["decision_id"],
"revision": row["revision"],
"classification": row["classification"],
"pairing": row["pairing"],
"status": exposed_status(row),
"amount": row["amount"],
"currency": row["currency"],
"effective_at": row["effective_at"],
"mode": row["mode"],
"locked": bool(row["locked"]),
"own_company_id": company_id,
"counterparty_company_id": (
row["payee_company_id"]
if row["payer_company_id"] == company_id
else row["payer_company_id"]
),
"counterparty_company_name": (
row["payee_company_name"]
if row["payer_company_id"] == company_id
else row["payer_company_name"]
),
"evidence_count": row["evidence_count"],
}
for row in rows
]
count = len(events)
return {
"pending_unilateral": count,
"pending_total": count,
"unilateral_events": events,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API payload helpers # API payload helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+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:
+313
View File
@@ -0,0 +1,313 @@
"""HEL-168: company workspace pending unilateral counts sync after confirm."""
from __future__ import annotations
import io
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000001001"
ACCOUNT_B = "6222000000001002"
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) -> list:
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
class CompanyWorkspaceTodoTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
from bank_importer.db import connect, migrate
connection = connect(cls.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
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
cls.initial_passwords: dict[str, str] = {}
cls.company_a = cls._create_company("甲公司", "ws-cashier-a")
cls.company_b = cls._create_company("乙公司", "ws-cashier-b")
cls.cashier_a = cls._login_company("ws-cashier-a")
cls.cashier_b = cls._login_company("ws-cashier-b")
cls._approve_account(cls.cashier_a, ACCOUNT_A)
cls._approve_account(cls.cashier_b, ACCOUNT_B)
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
@classmethod
def _create_company(cls, name: str, username: str) -> int:
status, _, data = cls.admin.post_json(
"/api/admin/companies",
{"name": name, "username": username},
)
assert status == 200, data
payload = as_json(data)
cls.initial_passwords[username] = payload["initial_password"]
return payload["company_id"]
@classmethod
def _login_company(cls, username: str) -> Client:
client = Client("127.0.0.1", cls.port)
initial = cls.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
@classmethod
def _approve_account(cls, company_client: Client, number: str) -> int:
status, _, data = company_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 = cls.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 _seed_unilateral(self, amount: str, at: str, cp_account: str = "9999999999999999") -> dict:
status, _, data = self.admin.post_multipart(
"/api/parse",
{"company_id": str(self.company_a)},
f"单边-{amount}.xlsx",
workbook_bytes([outgoing(ACCOUNT_A, cp_account, amount, at)]),
)
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
self.assertEqual(200, status, data)
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = self.cashier_a.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
self.assertEqual(200, status, data)
status, _, data = self.cashier_a.get("/api/company/workspace")
self.assertEqual(200, status, data)
events = [
e for e in as_json(data)["unilateral_events"] if e["amount"] == amount
]
self.assertEqual(1, len(events), data)
return events[0]
def _workspace(self, client: Client) -> dict:
status, _, data = client.get("/api/company/workspace")
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertEqual("ok", payload["status"])
return payload
def test_seed_three_confirm_sync_and_isolation(self) -> None:
# Seed 3 pending unilaterals for company A.
e1 = self._seed_unilateral("11.00", "2026-03-01 10:00:00")
e2 = self._seed_unilateral("22.00", "2026-03-02 10:00:00")
e3 = self._seed_unilateral("33.00", "2026-03-03 10:00:00")
seeded_ids = {e1["event_id"], e2["event_id"], e3["event_id"]}
ws = self._workspace(self.cashier_a)
own = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
self.assertEqual(3, len(own))
self.assertEqual(ws["pending_unilateral"], ws["pending_total"])
self.assertGreaterEqual(ws["pending_unilateral"], 3)
# 两处数字与列表同一权威口径
self.assertEqual(ws["pending_unilateral"], len(ws["unilateral_events"]))
# 另一公司看不到甲公司这 3 笔
ws_b = self._workspace(self.cashier_b)
self.assertEqual(
[],
[e for e in ws_b["unilateral_events"] if e["event_id"] in seeded_ids],
)
# match-exceptions 与 workspace 同集合
status, _, data = self.cashier_a.get("/api/company/match-exceptions")
self.assertEqual(200, status, data)
exceptions = as_json(data)["exceptions"]
exception_ids = {e["event_id"] for e in exceptions}
self.assertTrue(seeded_ids.issubset(exception_ids))
remaining = [e1, e2, e3]
for expected in (2, 1, 0):
event = remaining.pop(0)
status, _, data = self.cashier_a.post_json(
f"/api/company/transfer-events/{event['event_id']}/confirm",
{
"expected_revision": event["revision"],
"request_key": f"hel168-{event['event_id']}-ok",
"counterparty_company_id": self.company_b,
"reason": "公司端确认单边流水",
},
)
self.assertEqual(200, status, data)
body = as_json(data)
self.assertIn("workspace", body)
left = [
e
for e in body["workspace"]["unilateral_events"]
if e["event_id"] in seeded_ids
]
self.assertEqual(expected, len(left))
self.assertEqual(body["workspace"]["pending_unilateral"], len(body["workspace"]["unilateral_events"]))
# 刷新等价:重新 GET 仍为同步后的数字
ws = self._workspace(self.cashier_a)
left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
self.assertEqual(expected, len(left))
# 重复确认:事件已离开待确认集合且 request_key 不同 → 404,数字不得误减
status, _, data = self.cashier_a.post_json(
f"/api/company/transfer-events/{e1['event_id']}/confirm",
{
"expected_revision": e1["revision"],
"request_key": f"hel168-{e1['event_id']}-again",
"counterparty_company_id": self.company_b,
"reason": "公司端确认单边流水",
},
)
self.assertEqual(404, status, data)
ws = self._workspace(self.cashier_a)
left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
self.assertEqual(0, len(left))
# 同一 request_key 重放:幂等成功,数字不误减
e4 = self._seed_unilateral("44.00", "2026-03-04 10:00:00")
key = f"hel168-{e4['event_id']}-replay"
status, _, data = self.cashier_a.post_json(
f"/api/company/transfer-events/{e4['event_id']}/confirm",
{
"expected_revision": e4["revision"],
"request_key": key,
"counterparty_company_id": self.company_b,
"reason": "公司端确认单边流水",
},
)
self.assertEqual(200, status, data)
after_first = self._workspace(self.cashier_a)["pending_unilateral"]
status, _, data = self.cashier_a.post_json(
f"/api/company/transfer-events/{e4['event_id']}/confirm",
{
"expected_revision": e4["revision"],
"request_key": key,
"counterparty_company_id": self.company_b,
"reason": "公司端确认单边流水",
},
)
self.assertEqual(200, status, data)
self.assertEqual(after_first, as_json(data)["workspace"]["pending_unilateral"])
self.assertEqual(after_first, self._workspace(self.cashier_a)["pending_unilateral"])
# 失败请求不得误减:用不存在的事件
before = self._workspace(self.cashier_a)["pending_unilateral"]
status, _, data = self.cashier_a.post_json(
"/api/company/transfer-events/999999/confirm",
{
"expected_revision": 1,
"request_key": "hel168-fail",
"counterparty_company_id": self.company_b,
},
)
self.assertEqual(404, status, data)
self.assertEqual(before, self._workspace(self.cashier_a)["pending_unilateral"])
# 乙公司不能确认甲公司事件(即使猜到 id)
status, _, data = self.cashier_b.post_json(
f"/api/company/transfer-events/{e2['event_id']}/confirm",
{
"expected_revision": e2["revision"],
"request_key": "hel168-cross",
"counterparty_company_id": self.company_a,
},
)
self.assertEqual(404, status, data)
def test_admin_forbidden_on_company_workspace(self) -> None:
status, _, data = self.admin.get("/api/company/workspace")
self.assertEqual(403, status, data)
if __name__ == "__main__":
unittest.main()
+235 -88
View File
@@ -2548,100 +2548,245 @@ async function loadImportBatches() {
if (foot) foot.textContent = batches.length ? `${batches.length} 个批次` : "暂无批次"; if (foot) foot.textContent = batches.length ? `${batches.length} 个批次` : "暂无批次";
} }
function refreshWorkspacePending() { function formatWorkspaceAmount(amount, currency) {
const card = $("#workspaceTodos"); const n = Number(amount);
if (!card) return; const text = Number.isFinite(n)
const count = $$(".list-row", card).length; ? n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const status = $("#workspacePendingStatus"); : String(amount ?? "—");
if (status) status.textContent = `${count} 项待处理`; return `${currency || "CNY"} ${text}`;
} }
function initReconcile() { function applyCompanyWorkspace(payload) {
const matchCards = $$("[data-match-card]"); const events = Array.isArray(payload?.unilateral_events) ? payload.unilateral_events : [];
const subjectRows = $$("[data-subject-row]"); const pending = Number(payload?.pending_unilateral ?? events.length) || 0;
if (!matchCards.length && !subjectRows.length) return; const total = Number(payload?.pending_total ?? pending) || 0;
state.workspace = { pending_unilateral: pending, pending_total: total, unilateral_events: events };
const cta = $("#workspaceUnilateralCta");
if (cta) {
cta.textContent = pending ? `去确认单边流水 (${pending})` : "单边流水已全部确认";
cta.classList.toggle("btn-primary", pending > 0);
}
const status = $("#workspacePendingStatus");
if (status) {
status.textContent = total ? `${total} 项待处理` : "已完成";
status.className = `pill ${total ? "pill-warn" : "pill-success"}`;
}
const sub = $("#workspaceTodoSub");
if (sub) sub.textContent = total ? `权威待确认单边流水 ${pending}` : "本月单边流水待办已清空";
const list = $("#workspaceTodoList");
if (list) {
list.replaceChildren();
if (!events.length) {
const empty = document.createElement("div");
empty.className = "empty";
empty.style.padding = "18px 16px";
empty.innerHTML = '<div class="e-title">暂无待确认单边流水</div><div>刷新或重登后仍以服务端权威状态为准</div>';
list.append(empty);
} else {
events.forEach((event) => {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.taskType = "match";
row.dataset.eventId = String(event.event_id);
row.innerHTML = `
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">确认单边流水 · ${formatWorkspaceAmount(event.amount, event.currency)}</div>
<div class="lr-sub">${event.counterparty_company_name || "对方待指定"} · ${event.effective_at || "—"}</div>
</div>
<button class="btn btn-sm" data-view-link="reconcile">去确认</button>`;
list.append(row);
});
}
}
const foot = $("#workspaceTodoFoot");
if (foot) {
foot.innerHTML = total
? `<span>处理完 ${total} 笔单边流水后,工作台数字与列表将同步归零</span>`
: "<span>单边流水待办已完成</span>";
}
const flowState = $("#workspaceConfirmState");
if (flowState) flowState.textContent = pending ? `待处理 ${pending}` : "已完成";
const flowMeta = $("#workspaceConfirmMeta");
if (flowMeta) {
flowMeta.textContent = pending
? `单边流水 ${pending} 笔待确认`
: "已全部确认,等待集团结账";
}
const countMatch = $("#count-match");
if (countMatch) countMatch.textContent = String(pending);
const noticeTitle = $("#notice-title"); const noticeTitle = $("#notice-title");
const noticeBody = $("#notice-body"); const noticeBody = $("#notice-body");
const notice = $("#blocking-notice"); const notice = $("#blocking-notice");
const matchedSummary = $("#matched-summary"); if (noticeTitle) {
const matchedList = $("#matched-list"); noticeTitle.textContent = pending
const matchStack = $("#match-stack"); ? `${pending} 项单边流水待确认,是结账阻断项`
let pendingMatch = matchCards.length; : "单边流水已全部确认完成";
let pendingSubject = subjectRows.length; }
if (noticeBody) {
function refreshCounts() { noticeBody.textContent = pending
const countMatch = $("#count-match"); ? `工作台「去确认单边流水」与「本月待办」均读取同一权威集合(${pending} 笔)。确认成功后立即同步减一。`
const countSubject = $("#count-subject"); : "本公司单边流水待办已清空;刷新或重新登录后仍为 0。";
if (countMatch) countMatch.textContent = pendingMatch; }
if (countSubject) countSubject.textContent = pendingSubject; if (notice) {
const total = pendingMatch + pendingSubject; notice.classList.toggle("warn", pending > 0);
const badge = $('.side-nav a[data-view="reconcile"] .nav-badge'); notice.classList.toggle("success", pending === 0);
if (badge) {
badge.textContent = total;
badge.style.display = total ? "" : "none";
}
if (noticeTitle) {
noticeTitle.textContent = total ? `${total} 项待确认,是 7 月结账的阻断项` : "全部确认完成";
}
if (noticeBody) {
noticeBody.textContent = total
? `含单边流水匹配 ${pendingMatch} 项、科目确认 ${pendingSubject} 项。请于 2026-08-29(7 月顺延结账日)前处理完毕,否则集团无法对贵公司执行 7 月结账。`
: "本公司 2026-07 账期已具备结账条件,集团将于 08-29 统一执行结账。";
}
if (notice) {
notice.classList.toggle("warn", total > 0);
notice.classList.toggle("success", total === 0);
}
if (pendingMatch === 0) $('[data-task-type="match"]', $("#workspaceTodos"))?.remove();
const matchRow = $('[data-task-type="match"]', $("#workspaceTodos"));
if (matchRow) {
const title = $(".lr-title", matchRow);
if (title) title.textContent = `处理 ${pendingMatch} 笔单边流水确认`;
}
const subjectRow = $('[data-task-type="subject"]', $("#workspaceTodos"));
if (subjectRow) {
const title = $(".lr-title", subjectRow);
if (title) title.textContent = `确认 ${pendingSubject} 笔其他应收科目`;
}
const flowState = $("#workspaceConfirmState");
if (flowState) flowState.textContent = total ? `待处理 ${total}` : "已完成";
const flowMeta = $("#workspaceConfirmMeta");
if (flowMeta) flowMeta.textContent = total ? `单边流水 ${pendingMatch} 笔 · 科目确认 ${pendingSubject}` : "已全部确认,等待集团结账";
refreshWorkspacePending();
} }
matchCards.forEach((card) => { const badge = $('.side-nav a[data-view="reconcile"] .nav-badge');
const confirmButton = $("[data-match-confirm]", card); if (badge) {
const radios = $$('input[type="radio"]', card); badge.textContent = pending;
radios.forEach((radio) => radio.addEventListener("change", () => { if (confirmButton) confirmButton.disabled = false; })); badge.style.display = pending ? "" : "none";
confirmButton?.addEventListener("click", () => { }
const summaryText = confirmButton.dataset.matchSummary || "已确认匹配"; }
async function loadCompanyWorkspace() {
const response = await fetch("/api/company/workspace").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") {
applyCompanyWorkspace({ pending_unilateral: 0, pending_total: 0, unilateral_events: [] });
showToast("工作台待办读取失败", result?.message || "请稍后重试", "danger");
return null;
}
applyCompanyWorkspace(result);
return result;
}
function renderUnilateralMatchCard(event, peers) {
const card = document.createElement("div");
card.className = "card";
card.dataset.matchCard = "true";
card.dataset.eventId = String(event.event_id);
card.dataset.revision = String(event.revision ?? "");
const amountText = formatWorkspaceAmount(event.amount, event.currency);
const peerName = event.counterparty_company_name || "对方待指定";
const options = peers
.filter((p) => Number(p.id) !== Number(event.own_company_id))
.map((p) => {
const selected = Number(p.id) === Number(event.counterparty_company_id) ? " selected" : "";
return `<option value="${p.id}"${selected}>${p.name}</option>`;
})
.join("");
card.innerHTML = `
<div class="card-head">
<span class="card-title">${event.effective_at || "—"} · <span class="num">${amountText}</span>
<span class="sub">对方:${peerName}</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>事件编号</dt><dd class="num">${event.event_id}</dd>
<dt>状态</dt><dd>${event.status || event.classification || "待确认"}</dd>
<dt>金额</dt><dd class="num">${amountText}</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>确认对方公司</label>
<select class="select" data-counterparty-select>
<option value="">请选择对方公司</option>
${options}
</select>
<span class="hint">确认后按现有单边确认规则锁定对方参与方;工作台数字以服务端权威状态重拉。</span>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled>确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>`;
const select = $("[data-counterparty-select]", card);
const button = $("[data-match-confirm]", card);
const syncEnabled = () => {
if (button) button.disabled = !select?.value;
};
select?.addEventListener("change", syncEnabled);
syncEnabled();
button?.addEventListener("click", async () => {
if (!select?.value || button.dataset.busy === "1") return;
button.dataset.busy = "1";
button.disabled = true;
const requestKey = `company-confirm-${event.event_id}-${event.revision}-${select.value}`;
const response = await fetch(`/api/company/transfer-events/${event.event_id}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
expected_revision: event.revision,
request_key: requestKey,
counterparty_company_id: Number(select.value),
reason: "公司端确认单边流水",
}),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
button.dataset.busy = "0";
syncEnabled();
showToast("确认失败", result?.message || "请刷新后重试", "danger");
// 失败不得本地误减:重拉权威状态
await loadCompanyWorkspace();
await renderReconcileMatchStack();
return;
}
const matchedList = $("#matched-list");
const matchedSummary = $("#matched-summary");
if (matchedList) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "list-row"; row.className = "list-row";
const main = document.createElement("div"); row.innerHTML = `<div class="lr-main"><div class="lr-title">${amountText} · 已确认对方 ${select.selectedOptions[0]?.textContent || ""}</div></div><span class="pill pill-success lr-side">已匹配</span>`;
main.className = "lr-main";
const title = document.createElement("div");
title.className = "lr-title";
title.textContent = summaryText;
main.append(title);
const pill = document.createElement("span");
pill.className = "pill pill-success lr-side";
pill.textContent = "已匹配";
row.append(main, pill);
matchedList.append(row); matchedList.append(row);
matchedSummary.style.display = ""; if (matchedSummary) matchedSummary.style.display = "";
card.remove(); }
pendingMatch -= 1; if (result.workspace) applyCompanyWorkspace(result.workspace);
if (pendingMatch === 0) { else await loadCompanyWorkspace();
const empty = document.createElement("div"); await renderReconcileMatchStack();
empty.className = "empty"; showToast("匹配已确认", "工作台待办已按权威状态同步", "success");
empty.innerHTML = '<div class="e-title">单边流水已全部匹配</div><div>确认结果将同步至集团审核中心复核</div>';
matchStack.append(empty);
}
refreshCounts();
showToast("匹配已确认", "待办状态、操作人、时间和依据已同步更新", "success");
});
}); });
return card;
}
async function loadCompanyPeerOptions() {
if (Array.isArray(state.companyPeers) && state.companyPeers.length) return state.companyPeers;
const response = await fetch("/api/company/companies").catch(() => null);
const result = await response?.json().catch(() => null);
const peers = Array.isArray(result?.companies) ? result.companies : [];
state.companyPeers = peers;
return peers;
}
async function renderReconcileMatchStack() {
const stack = $("#match-stack");
if (!stack) return;
const events = state.workspace?.unilateral_events || [];
const peers = await loadCompanyPeerOptions();
stack.replaceChildren();
if (!events.length) {
const empty = document.createElement("div");
empty.className = "empty";
empty.innerHTML = '<div class="e-title">单边流水已全部匹配</div><div>确认结果已同步至工作台权威待办</div>';
stack.append(empty);
return;
}
events.forEach((event) => stack.append(renderUnilateralMatchCard(event, peers)));
}
function initReconcile() {
const subjectRows = $$("[data-subject-row]");
let pendingSubject = subjectRows.length;
const countSubject = $("#count-subject");
if (countSubject) countSubject.textContent = pendingSubject;
$$(".subject-confirm-btn").forEach((button) => { $$(".subject-confirm-btn").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
@@ -2654,8 +2799,8 @@ function initReconcile() {
const statusCell = $(".subject-status", row); const statusCell = $(".subject-status", row);
statusCell.innerHTML = `<span class="pill pill-success">已确认 · ${subject}</span>`; statusCell.innerHTML = `<span class="pill pill-success">已确认 · ${subject}</span>`;
pendingSubject -= 1; pendingSubject -= 1;
refreshCounts(); if (countSubject) countSubject.textContent = pendingSubject;
showToast("科目已确认", "待办状态、操作人、时间和依据已同步更新", "success"); showToast("科目已确认", "科目确认仍为页面演示,不计入工作台权威待办", "success");
}); });
}); });
@@ -2673,8 +2818,6 @@ function initReconcile() {
} }
tabMatch?.addEventListener("click", () => switchTab("match")); tabMatch?.addEventListener("click", () => switchTab("match"));
tabSubject?.addEventListener("click", () => switchTab("subject")); tabSubject?.addEventListener("click", () => switchTab("subject"));
refreshCounts();
} }
function initNotifications() { function initNotifications() {
@@ -2916,8 +3059,12 @@ function initCompany() {
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success"); showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
}); });
// ── 往来确认 ── // ── 往来确认 + 工作台权威待办 ──
initReconcile(); initReconcile();
(async () => {
await loadCompanyWorkspace();
await renderReconcileMatchStack();
})();
// ── 通知 ── // ── 通知 ──
initNotifications(); initNotifications();
+13 -156
View File
@@ -57,7 +57,7 @@
</div> </div>
<div class="page-actions"> <div class="page-actions">
<button class="btn" data-open-upload>上传流水</button> <button class="btn" data-open-upload>上传流水</button>
<button class="btn btn-primary" data-view-link="reconcile">去确认单边流水 (3)</button> <button class="btn btn-primary" id="workspaceUnilateralCta" data-view-link="reconcile">去确认单边流水 (0)</button>
</div> </div>
</div> </div>
@@ -79,8 +79,8 @@
</a> </a>
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile"> <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-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
<div class="fs-state" id="workspaceConfirmState">待处理 5 笔</div> <div class="fs-state" id="workspaceConfirmState">加载中…</div>
<div class="fs-meta" id="workspaceConfirmMeta">单边流水 3 笔 · 科目确认 2 笔</div> <div class="fs-meta" id="workspaceConfirmMeta">正在读取待确认单边流水</div>
</a> </a>
<div class="flow-step wait"> <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-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
@@ -99,43 +99,12 @@
<div class="stack"> <div class="stack">
<div class="card" id="workspaceTodos"> <div class="card" id="workspaceTodos">
<div class="card-head"> <div class="card-head">
<span class="card-title">本月待办<span class="sub">本月任务 13/18 · 按处理顺序排列,前 3 项阻断结账</span></span> <span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">按权威待确认单边流水同步</span></span>
<span class="pill pill-warn" id="workspacePendingStatus">4 项待处理</span> <span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
</div> </div>
<div class="list-row" data-task-type="match" data-detail="task-match"> <div id="workspaceTodoList"></div>
<span class="pill pill-danger">阻断</span> <div class="table-foot" id="workspaceTodoFoot">
<div class="lr-main"> <span>正在读取待办…</span>
<div class="lr-title">处理 3 笔单边流水确认</div>
<div class="lr-sub">选择对方银行流水作为证据后提交确认</div>
</div>
<button class="btn btn-sm" data-view-link="reconcile">去确认</button>
</div>
<div class="list-row" data-task-type="subject" data-detail="task-subject">
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">确认 2 笔其他应收科目</div>
<div class="lr-sub">核对后改判或维持原科目</div>
</div>
<button class="btn btn-sm" data-view-link="manual">去处理</button>
</div>
<div class="list-row" data-detail="task-upload">
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">补传交行尾号 7710 账户 7 月流水</div>
<div class="lr-sub">账户已登记,待总行审核通过后即可导入</div>
</div>
<button class="btn btn-sm" data-view-link="accounts">查看账户</button>
</div>
<div class="list-row" data-detail="task-reconcile">
<span class="pill pill-muted">一般</span>
<div class="lr-main">
<div class="lr-title">核对与金牛置业 320 万往来</div>
<div class="lr-sub">置业 8821 账户 7 月流水断档,需人工核对</div>
</div>
<button class="btn btn-sm" data-view-link="flows">去核对</button>
</div>
<div class="table-foot">
<span>完成以上 4 项后,7 月账期即可提交集团结账</span>
</div> </div>
</div> </div>
@@ -463,130 +432,18 @@
<div class="notice warn" id="blocking-notice" style="margin-bottom: 16px;"> <div class="notice warn" id="blocking-notice" style="margin-bottom: 16px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 18px; height: 18px; flex: none; margin-top: 1px;"><path d="M12 8v5M12 16.5v.01"/><path d="M10.3 4.1L2.8 17a2 2 0 0 0 1.7 3h15a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 18px; height: 18px; flex: none; margin-top: 1px;"><path d="M12 8v5M12 16.5v.01"/><path d="M10.3 4.1L2.8 17a2 2 0 0 0 1.7 3h15a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0z"/></svg>
<div> <div>
<div class="n-title" id="notice-title">5 项待确认,是 7 月结账的阻断项</div> <div class="n-title" id="notice-title">正在读取待确认单边流水…</div>
<div class="n-body" id="notice-body">含单边流水匹配 3 项、科目确认 2 项。请于 2026-08-29(7 月顺延结账日)前处理完毕,否则集团无法对贵公司执行 7 月结账</div> <div class="n-body" id="notice-body">工作台与往来确认共用同一权威待确认集合</div>
</div> </div>
</div> </div>
<div class="tabs"> <div class="tabs">
<button type="button" class="active" id="tab-match" aria-pressed="true">单边流水匹配<span class="tab-count" id="count-match">3</span></button> <button type="button" class="active" id="tab-match" aria-pressed="true">单边流水匹配<span class="tab-count" id="count-match">0</span></button>
<button type="button" id="tab-subject" aria-pressed="false">科目确认<span class="tab-count" id="count-subject">2</span></button> <button type="button" id="tab-subject" aria-pressed="false">科目确认<span class="tab-count" id="count-subject">2</span></button>
</div> </div>
<section id="panel-match"> <section id="panel-match">
<div class="stack" id="match-stack"> <div class="stack" id="match-stack"></div>
<div class="card" data-match-card>
<div class="card-head">
<span class="card-title">2026-07-14 付 <span class="num amt-out">¥3,200,000.00</span> 给金牛置业<span class="sub">摘要:煤炭采购款</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>本方流水号</dt><dd>MY-ICBC-3305-20260714-018</dd>
<dt>本方账户</dt><dd>工商银行基本户 · 尾号 3305</dd>
<dt>对方户名</dt><dd>河南金牛置业有限公司</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>候选对方银行证据(选择 1 条进行匹配)</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-1" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛置业 · 建设银行一般户 · 尾号 6642</div>
<div class="lr-sub num">2026-07-14 收 ¥3,200,000.00 · 对方流水号 ZY-CCB-6642-20260714-006</div>
</div>
<span class="pill pill-success lr-side">匹配度 高</span>
</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-1" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛置业 · 中国银行基本户 · 尾号 8821</div>
<div class="lr-sub num">2026-07-15 收 ¥3,200,000.00 · 对方流水号 ZY-BOC-8821-20260715-011</div>
</div>
<span class="pill pill-warn lr-side">匹配度 中</span>
</label>
<span class="hint">提示:置业中行尾号 8821 账户 07-06 至 07-16 流水断档,其 07-15 记录为对方后补手工记录,匹配度较低,请优先核对建行 6642。</span>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled data-match-summary="2026-07-14 付 ¥3,200,000.00 给金牛置业 · 煤炭采购款 · 已匹配置业建行 6642 收款记录">确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>
</div>
<div class="card" data-match-card>
<div class="card-head">
<span class="card-title">2026-07-22 付 <span class="num amt-out">¥950,000.00</span> 给金牛物流<span class="sub">摘要:7 月矿区运输费</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>本方流水号</dt><dd>MY-ICBC-3305-20260722-031</dd>
<dt>本方账户</dt><dd>工商银行基本户 · 尾号 3305</dd>
<dt>对方户名</dt><dd>河南金牛物流有限公司</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>候选对方银行证据(选择 1 条进行匹配)</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-2" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛物流 · 农业银行基本户 · 尾号 5508</div>
<div class="lr-sub num">2026-07-22 收 ¥950,000.00 · 对方流水号 WL-ABC-5508-20260722-014</div>
</div>
<span class="pill pill-success lr-side">匹配度 高</span>
</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-2" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛物流 · 工商银行一般户 · 尾号 2201</div>
<div class="lr-sub num">2026-07-23 收 ¥950,000.00 · 对方流水号 WL-ICBC-2201-20260723-002</div>
</div>
<span class="pill pill-warn lr-side">匹配度 中</span>
</label>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled data-match-summary="2026-07-22 付 ¥950,000.00 给金牛物流 · 7 月矿区运输费 · 已匹配物流农行 5508 收款记录">确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>
</div>
<div class="card" data-match-card>
<div class="card-head">
<span class="card-title">2026-07-09 收 <span class="num amt-in">¥1,860,000.00</span> 自金牛贸易<span class="sub">摘要:精煤销售款</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>本方流水号</dt><dd>MY-BOC-9916-20260709-007</dd>
<dt>本方账户</dt><dd>中国银行一般户 · 尾号 9916</dd>
<dt>对方户名</dt><dd>河南金牛贸易有限公司</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>候选对方银行证据(选择 1 条进行匹配)</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-3" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛贸易 · 交通银行基本户 · 尾号 8077</div>
<div class="lr-sub num">2026-07-09 付 ¥1,860,000.00 · 对方流水号 MY-BCM-8077-20260709-019</div>
</div>
<span class="pill pill-success lr-side">匹配度 高</span>
</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-3" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛贸易 · 中国银行一般户 · 尾号 3340</div>
<div class="lr-sub num">2026-07-08 付 ¥1,860,000.00 · 对方流水号 MY-BOC-3340-20260708-021</div>
</div>
<span class="pill pill-warn lr-side">匹配度 中</span>
</label>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled data-match-summary="2026-07-09 收 ¥1,860,000.00 自金牛贸易 · 精煤销售款 · 已匹配贸易交行 8077 付款记录">确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>
</div>
</div>
<div class="card" style="margin-top: 14px; display: none;" id="matched-summary"> <div class="card" style="margin-top: 14px; display: none;" id="matched-summary">
<div class="card-head"> <div class="card-head">
@@ -957,6 +814,6 @@
</div> </div>
</div> </div>
<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=6"></script> <script src="app.js?v=10"></script>
</body> </body>
</html> </html>