HEL-168: 公司端工作台待办按权威单边集合同步
确认成功后重拉 /api/company/workspace,去确认数字与本月待办共用同一口径; 新增公司端确认接口与 3→2→1→0 集成测试,失败/重复确认不误减。 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
eefdc92ab6
commit
bf5754ee09
@@ -115,6 +115,9 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if path == "/api/company/match-exceptions":
|
||||
self._handle_company_match_exceptions(query)
|
||||
return
|
||||
if path == "/api/company/workspace":
|
||||
self._handle_company_workspace()
|
||||
return
|
||||
company_event_match = re.fullmatch(r"/api/company/transfer-events/(\d+)", path)
|
||||
if company_event_match:
|
||||
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":
|
||||
self._handle_company_manual_records_submit()
|
||||
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": "接口不存在。"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2195,6 +2204,8 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
)
|
||||
return {
|
||||
"event_id": row["event_id"],
|
||||
"decision_id": row["decision_id"],
|
||||
"revision": row["revision"],
|
||||
"classification": row["classification"],
|
||||
"pairing": row["pairing"],
|
||||
"status": matching.exposed_status(row),
|
||||
@@ -2209,6 +2220,20 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
"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:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
@@ -2218,13 +2243,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if user["role"] != "company":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
||||
return
|
||||
params: list[object] = [user["company_id"], 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()
|
||||
rows = matching.company_pending_unilaterals(connection, user["company_id"])
|
||||
items = [
|
||||
self._company_event_list_item(row, user["company_id"]) for row in rows
|
||||
]
|
||||
@@ -2232,6 +2251,109 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
finally:
|
||||
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:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
|
||||
@@ -1563,6 +1563,87 @@ def exposed_status(decision: sqlite3.Row | dict) -> str:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -2548,100 +2548,245 @@ async function loadImportBatches() {
|
||||
if (foot) foot.textContent = batches.length ? `共 ${batches.length} 个批次` : "暂无批次";
|
||||
}
|
||||
|
||||
function refreshWorkspacePending() {
|
||||
const card = $("#workspaceTodos");
|
||||
if (!card) return;
|
||||
const count = $$(".list-row", card).length;
|
||||
const status = $("#workspacePendingStatus");
|
||||
if (status) status.textContent = `${count} 项待处理`;
|
||||
function formatWorkspaceAmount(amount, currency) {
|
||||
const n = Number(amount);
|
||||
const text = Number.isFinite(n)
|
||||
? n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: String(amount ?? "—");
|
||||
return `${currency || "CNY"} ${text}`;
|
||||
}
|
||||
|
||||
function initReconcile() {
|
||||
const matchCards = $$("[data-match-card]");
|
||||
const subjectRows = $$("[data-subject-row]");
|
||||
if (!matchCards.length && !subjectRows.length) return;
|
||||
function applyCompanyWorkspace(payload) {
|
||||
const events = Array.isArray(payload?.unilateral_events) ? payload.unilateral_events : [];
|
||||
const pending = Number(payload?.pending_unilateral ?? events.length) || 0;
|
||||
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 noticeBody = $("#notice-body");
|
||||
const notice = $("#blocking-notice");
|
||||
const matchedSummary = $("#matched-summary");
|
||||
const matchedList = $("#matched-list");
|
||||
const matchStack = $("#match-stack");
|
||||
let pendingMatch = matchCards.length;
|
||||
let pendingSubject = subjectRows.length;
|
||||
|
||||
function refreshCounts() {
|
||||
const countMatch = $("#count-match");
|
||||
const countSubject = $("#count-subject");
|
||||
if (countMatch) countMatch.textContent = pendingMatch;
|
||||
if (countSubject) countSubject.textContent = pendingSubject;
|
||||
const total = pendingMatch + pendingSubject;
|
||||
const badge = $('.side-nav a[data-view="reconcile"] .nav-badge');
|
||||
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();
|
||||
if (noticeTitle) {
|
||||
noticeTitle.textContent = pending
|
||||
? `${pending} 项单边流水待确认,是结账阻断项`
|
||||
: "单边流水已全部确认完成";
|
||||
}
|
||||
if (noticeBody) {
|
||||
noticeBody.textContent = pending
|
||||
? `工作台「去确认单边流水」与「本月待办」均读取同一权威集合(${pending} 笔)。确认成功后立即同步减一。`
|
||||
: "本公司单边流水待办已清空;刷新或重新登录后仍为 0。";
|
||||
}
|
||||
if (notice) {
|
||||
notice.classList.toggle("warn", pending > 0);
|
||||
notice.classList.toggle("success", pending === 0);
|
||||
}
|
||||
|
||||
matchCards.forEach((card) => {
|
||||
const confirmButton = $("[data-match-confirm]", card);
|
||||
const radios = $$('input[type="radio"]', card);
|
||||
radios.forEach((radio) => radio.addEventListener("change", () => { if (confirmButton) confirmButton.disabled = false; }));
|
||||
confirmButton?.addEventListener("click", () => {
|
||||
const summaryText = confirmButton.dataset.matchSummary || "已确认匹配";
|
||||
const badge = $('.side-nav a[data-view="reconcile"] .nav-badge');
|
||||
if (badge) {
|
||||
badge.textContent = pending;
|
||||
badge.style.display = pending ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
row.className = "list-row";
|
||||
const main = document.createElement("div");
|
||||
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);
|
||||
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>`;
|
||||
matchedList.append(row);
|
||||
matchedSummary.style.display = "";
|
||||
card.remove();
|
||||
pendingMatch -= 1;
|
||||
if (pendingMatch === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.innerHTML = '<div class="e-title">单边流水已全部匹配</div><div>确认结果将同步至集团审核中心复核</div>';
|
||||
matchStack.append(empty);
|
||||
}
|
||||
refreshCounts();
|
||||
showToast("匹配已确认", "待办状态、操作人、时间和依据已同步更新", "success");
|
||||
});
|
||||
if (matchedSummary) matchedSummary.style.display = "";
|
||||
}
|
||||
if (result.workspace) applyCompanyWorkspace(result.workspace);
|
||||
else await loadCompanyWorkspace();
|
||||
await renderReconcileMatchStack();
|
||||
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) => {
|
||||
button.addEventListener("click", () => {
|
||||
@@ -2654,8 +2799,8 @@ function initReconcile() {
|
||||
const statusCell = $(".subject-status", row);
|
||||
statusCell.innerHTML = `<span class="pill pill-success">已确认 · ${subject}</span>`;
|
||||
pendingSubject -= 1;
|
||||
refreshCounts();
|
||||
showToast("科目已确认", "待办状态、操作人、时间和依据已同步更新", "success");
|
||||
if (countSubject) countSubject.textContent = pendingSubject;
|
||||
showToast("科目已确认", "科目确认仍为页面演示,不计入工作台权威待办", "success");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2673,8 +2818,6 @@ function initReconcile() {
|
||||
}
|
||||
tabMatch?.addEventListener("click", () => switchTab("match"));
|
||||
tabSubject?.addEventListener("click", () => switchTab("subject"));
|
||||
|
||||
refreshCounts();
|
||||
}
|
||||
|
||||
function initNotifications() {
|
||||
@@ -2916,8 +3059,12 @@ function initCompany() {
|
||||
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
|
||||
});
|
||||
|
||||
// ── 往来确认 ──
|
||||
// ── 往来确认 + 工作台权威待办 ──
|
||||
initReconcile();
|
||||
(async () => {
|
||||
await loadCompanyWorkspace();
|
||||
await renderReconcileMatchStack();
|
||||
})();
|
||||
|
||||
// ── 通知 ──
|
||||
initNotifications();
|
||||
|
||||
+13
-156
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<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>
|
||||
|
||||
@@ -79,8 +79,8 @@
|
||||
</a>
|
||||
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
|
||||
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
|
||||
<div class="fs-state" id="workspaceConfirmState">待处理 5 笔</div>
|
||||
<div class="fs-meta" id="workspaceConfirmMeta">单边流水 3 笔 · 科目确认 2 笔</div>
|
||||
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
|
||||
<div class="fs-meta" id="workspaceConfirmMeta">正在读取待确认单边流水</div>
|
||||
</a>
|
||||
<div class="flow-step wait">
|
||||
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
|
||||
@@ -99,43 +99,12 @@
|
||||
<div class="stack">
|
||||
<div class="card" id="workspaceTodos">
|
||||
<div class="card-head">
|
||||
<span class="card-title">本月待办<span class="sub">本月任务 13/18 · 按处理顺序排列,前 3 项阻断结账</span></span>
|
||||
<span class="pill pill-warn" id="workspacePendingStatus">4 项待处理</span>
|
||||
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">按权威待确认单边流水同步</span></span>
|
||||
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
|
||||
</div>
|
||||
<div class="list-row" data-task-type="match" data-detail="task-match">
|
||||
<span class="pill pill-danger">阻断</span>
|
||||
<div class="lr-main">
|
||||
<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 id="workspaceTodoList"></div>
|
||||
<div class="table-foot" id="workspaceTodoFoot">
|
||||
<span>正在读取待办…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -463,130 +432,18 @@
|
||||
<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>
|
||||
<div>
|
||||
<div class="n-title" id="notice-title">5 项待确认,是 7 月结账的阻断项</div>
|
||||
<div class="n-body" id="notice-body">含单边流水匹配 3 项、科目确认 2 项。请于 2026-08-29(7 月顺延结账日)前处理完毕,否则集团无法对贵公司执行 7 月结账。</div>
|
||||
<div class="n-title" id="notice-title">正在读取待确认单边流水…</div>
|
||||
<div class="n-body" id="notice-body">工作台与往来确认共用同一权威待确认集合。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<section id="panel-match">
|
||||
<div class="stack" id="match-stack">
|
||||
<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="stack" id="match-stack"></div>
|
||||
|
||||
<div class="card" style="margin-top: 14px; display: none;" id="matched-summary">
|
||||
<div class="card-head">
|
||||
@@ -957,6 +814,6 @@
|
||||
</div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user