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: