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:
施工员
2026-08-27 12:10:38 +00:00
co-authored by Cursor multica-agent
parent eefdc92ab6
commit bf5754ee09
5 changed files with 771 additions and 251 deletions
+129 -7
View File
@@ -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:
+81
View File
@@ -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
# ---------------------------------------------------------------------------
+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} 个批次` : "暂无批次";
}
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
View File
@@ -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>