B-43: 双边流水归并与规范事件层——迁移5、匹配引擎、个人过账映射与事件API
This commit is contained in:
@@ -10,7 +10,7 @@ from http.cookies import SimpleCookie
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from bank_importer import auth, importing, master_data, multipart
|
||||
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
@@ -77,6 +77,29 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if path == "/api/admin/audit-log":
|
||||
self._handle_admin_audit_log(query)
|
||||
return
|
||||
if path == "/api/admin/transfer-events":
|
||||
self._handle_admin_transfer_events(query)
|
||||
return
|
||||
if path == "/api/admin/match-exceptions":
|
||||
self._handle_admin_match_exceptions(query)
|
||||
return
|
||||
if path == "/api/admin/personal-transit-mappings":
|
||||
self._handle_admin_personal_mappings(query)
|
||||
return
|
||||
event_match = re.fullmatch(r"/api/admin/transfer-events/(\d+)", path)
|
||||
if event_match:
|
||||
self._handle_admin_transfer_event_detail(int(event_match.group(1)))
|
||||
return
|
||||
if path == "/api/company/transfer-events":
|
||||
self._handle_company_transfer_events(query)
|
||||
return
|
||||
if path == "/api/company/match-exceptions":
|
||||
self._handle_company_match_exceptions(query)
|
||||
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)))
|
||||
return
|
||||
|
||||
if path == "/admin.html" and not self._guard_page("admin"):
|
||||
return
|
||||
@@ -130,6 +153,20 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if ignore_match:
|
||||
self._handle_batch_review(int(ignore_match.group(1)), "ignore")
|
||||
return
|
||||
if path == "/api/admin/transfer-events/reconcile":
|
||||
self._handle_admin_reconcile()
|
||||
return
|
||||
if path == "/api/admin/personal-transit-mappings":
|
||||
self._handle_admin_create_personal_mapping()
|
||||
return
|
||||
decision_match = re.fullmatch(r"/api/admin/transfer-events/(\d+)/decisions", path)
|
||||
if decision_match:
|
||||
self._handle_admin_transfer_decision(int(decision_match.group(1)))
|
||||
return
|
||||
mapping_review = re.fullmatch(r"/api/admin/personal-transit-mappings/(\d+)/review", path)
|
||||
if mapping_review:
|
||||
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
|
||||
return
|
||||
self._send_json(404, {"status": "error", "message": "接口不存在。"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -339,7 +376,10 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
# Tenant binding comes from the session only; any
|
||||
# company_id in the multipart body is ignored.
|
||||
company_id = user["company_id"]
|
||||
if not self._validate_upload_account(connection, user, fields):
|
||||
upload_account_id, invalid = self._resolve_upload_account(
|
||||
connection, user, fields, company_id
|
||||
)
|
||||
if invalid:
|
||||
return
|
||||
else:
|
||||
company_id = self._parse_company_field(
|
||||
@@ -347,10 +387,16 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
)
|
||||
if company_id is None:
|
||||
return
|
||||
upload_account_id, invalid = self._resolve_upload_account(
|
||||
connection, user, fields, company_id
|
||||
)
|
||||
if invalid:
|
||||
return
|
||||
|
||||
result = importing.import_statement_path(
|
||||
connection, STORAGE_DIR, filename, upload.path,
|
||||
company_id=company_id,
|
||||
upload_bank_account_id=upload_account_id,
|
||||
)
|
||||
auth.audit(
|
||||
connection,
|
||||
@@ -410,32 +456,33 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
raise ValueError(str(exc)) from exc
|
||||
return fields, upload
|
||||
|
||||
def _validate_upload_account(self, connection, user, fields) -> bool:
|
||||
"""Validate the optional bank_account_id on a company upload.
|
||||
def _resolve_upload_account(self, connection, user, fields, company_id) -> tuple[int | None, bool]:
|
||||
"""Validate and return the approved upload account for a batch.
|
||||
|
||||
When supplied, the account must belong to the session company and be
|
||||
enabled inside its effective interval — pending, returned or disabled
|
||||
accounts never accept uploads.
|
||||
Returns ``(account_id, False)`` when the account is accepted or when
|
||||
none was supplied, and ``(None, True)`` after sending the rejection.
|
||||
The approved account is persisted on the batch so ownership can be
|
||||
resolved later even when source rows lack an own account.
|
||||
"""
|
||||
raw = fields.get("bank_account_id")
|
||||
if not raw:
|
||||
return True
|
||||
return None, False
|
||||
try:
|
||||
account_id = int(raw)
|
||||
except ValueError:
|
||||
self._send_json(400, {"status": "error", "message": "bank_account_id 参数无效。"})
|
||||
return False
|
||||
return None, True
|
||||
account = master_data.get_account(connection, account_id)
|
||||
if account is None or account["company_id"] != user["company_id"]:
|
||||
if account is None or account["company_id"] != company_id:
|
||||
self._send_json(404, {"status": "error", "message": "账户不存在。"})
|
||||
return False
|
||||
return None, True
|
||||
if not master_data.is_usable(account, master_data.utc_today()):
|
||||
self._send_json(
|
||||
409,
|
||||
{"status": "error", "message": "该账户未启用或已超出有效期,不能上传流水。"},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
return None, True
|
||||
return account_id, False
|
||||
|
||||
def _parse_company_field(self, connection, raw) -> int | None:
|
||||
try:
|
||||
@@ -600,16 +647,24 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except Exception:
|
||||
# Confirmation and matching share one transaction; any failure
|
||||
# rolls both back (the sheet stays pending).
|
||||
self._send_json(
|
||||
500,
|
||||
{"status": "error", "message": "确认失败,本次确认与匹配已整体回滚。"},
|
||||
)
|
||||
return
|
||||
rows = importing.sheet_review_rows(connection, batch_id)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"updated": outcome["updated"],
|
||||
"already": outcome["already"],
|
||||
"sheets": [self._sheet_payload(row) for row in rows],
|
||||
},
|
||||
)
|
||||
payload: dict[str, object] = {
|
||||
"status": "ok",
|
||||
"updated": outcome["updated"],
|
||||
"already": outcome["already"],
|
||||
"sheets": [self._sheet_payload(row) for row in rows],
|
||||
}
|
||||
if outcome.get("matching") is not None:
|
||||
payload["matching"] = outcome["matching"]
|
||||
self._send_json(200, payload)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@@ -1261,6 +1316,664 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Canonical transfer events (admin)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _event_base_sql(company_scope: bool) -> str:
|
||||
"""Shared list SELECT; ``company_scope`` filters to one company."""
|
||||
where = (
|
||||
"WHERE payer.company_id = ? OR payee.company_id = ?"
|
||||
if company_scope
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
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
|
||||
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}
|
||||
"""
|
||||
|
||||
def _handle_admin_transfer_events(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
raw_company = (query.get("company_id") or [None])[0]
|
||||
if raw_company:
|
||||
try:
|
||||
params.append(int(raw_company))
|
||||
params.append(int(raw_company))
|
||||
except ValueError:
|
||||
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
||||
return
|
||||
conditions.append("(payer.company_id = ? OR payee.company_id = ?)")
|
||||
raw_from = (query.get("from") or [None])[0]
|
||||
if raw_from:
|
||||
conditions.append("d.effective_at >= ?")
|
||||
params.append(raw_from)
|
||||
raw_to = (query.get("to") or [None])[0]
|
||||
if raw_to:
|
||||
conditions.append("d.effective_at <= ?")
|
||||
params.append(raw_to)
|
||||
raw_limit = (query.get("limit") or ["200"])[0]
|
||||
try:
|
||||
limit = max(1, min(int(raw_limit), 500))
|
||||
except ValueError:
|
||||
limit = 200
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
rows = connection.execute(
|
||||
self._event_base_sql(False) + where + " ORDER BY d.id DESC LIMIT ?",
|
||||
(*params, limit),
|
||||
).fetchall()
|
||||
items = [self._admin_event_list_item(row) for row in rows]
|
||||
status = (query.get("status") or [None])[0]
|
||||
if status:
|
||||
items = [item for item in items if item["status"] == status]
|
||||
self._send_json(200, {"status": "ok", "events": items})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _admin_event_list_item(row) -> dict[str, object]:
|
||||
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),
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"effective_at": row["effective_at"],
|
||||
"mode": row["mode"],
|
||||
"locked": bool(row["locked"]),
|
||||
"rule_version": row["rule_version"],
|
||||
"payer_company_id": row["payer_company_id"],
|
||||
"payer_company_name": row["payer_company_name"],
|
||||
"payee_company_id": row["payee_company_id"],
|
||||
"payee_company_name": row["payee_company_name"],
|
||||
"evidence_count": row["evidence_count"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
def _handle_admin_transfer_event_detail(self, event_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
payload = self._admin_event_detail(connection, event_id)
|
||||
if payload is None:
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "event": payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _admin_event_detail(self, connection, event_id: int) -> dict[str, object] | None:
|
||||
current = matching._current_decision_for_event(connection, event_id)
|
||||
if current is None:
|
||||
return None
|
||||
decisions = connection.execute(
|
||||
"SELECT * FROM transfer_match_decisions WHERE event_id = ? ORDER BY revision",
|
||||
(event_id,),
|
||||
).fetchall()
|
||||
participants = matching._decision_participants(connection, current["id"])
|
||||
observations = matching._decision_observations(connection, current["id"])
|
||||
candidates = connection.execute(
|
||||
"SELECT * FROM transfer_match_candidates WHERE decision_id = ? ORDER BY id",
|
||||
(current["id"],),
|
||||
).fetchall()
|
||||
observation_rows = []
|
||||
for observation in observations:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
|
||||
r.own_account, r.own_name, r.counterparty_account,
|
||||
r.counterparty_name, r.summary, r.reference, r.currency,
|
||||
s.sheet_name, f.original_filename,
|
||||
b.company_id AS batch_company_id
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN import_batches b ON b.id = s.import_batch_id
|
||||
JOIN source_files f ON f.id = b.source_file_id
|
||||
WHERE r.id = ?
|
||||
""",
|
||||
(observation["source_row_id"],),
|
||||
).fetchone()
|
||||
observation_rows.append(
|
||||
{
|
||||
"source_row_id": observation["source_row_id"],
|
||||
"role": observation["role"],
|
||||
"source_row": row["source_row"] if row else None,
|
||||
"sheet_name": row["sheet_name"] if row else None,
|
||||
"original_filename": row["original_filename"] if row else None,
|
||||
"company_id": row["batch_company_id"] if row else None,
|
||||
"transaction_at": row["transaction_at"] if row else None,
|
||||
"income": row["income"] if row else None,
|
||||
"expense": row["expense"] if row else None,
|
||||
"counterparty_name": row["counterparty_name"] if row else None,
|
||||
}
|
||||
)
|
||||
history = [
|
||||
{
|
||||
"decision_id": decision["id"],
|
||||
"revision": decision["revision"],
|
||||
"classification": decision["classification"],
|
||||
"pairing": decision["pairing"],
|
||||
"status": matching.exposed_status(decision),
|
||||
"amount": decision["amount"],
|
||||
"currency": decision["currency"],
|
||||
"effective_at": decision["effective_at"],
|
||||
"mode": decision["mode"],
|
||||
"locked": bool(decision["locked"]),
|
||||
"rule_version": decision["rule_version"],
|
||||
"reason": decision["reason"],
|
||||
"actor_username": decision["actor_username"],
|
||||
"supersedes_decision_id": decision["supersedes_decision_id"],
|
||||
"created_at": decision["created_at"],
|
||||
}
|
||||
for decision in decisions
|
||||
]
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"decision_id": current["id"],
|
||||
"revision": current["revision"],
|
||||
"classification": current["classification"],
|
||||
"pairing": current["pairing"],
|
||||
"status": matching.exposed_status(current),
|
||||
"amount": current["amount"],
|
||||
"currency": current["currency"],
|
||||
"effective_at": current["effective_at"],
|
||||
"mode": current["mode"],
|
||||
"locked": bool(current["locked"]),
|
||||
"rule_version": current["rule_version"],
|
||||
"participants": [
|
||||
{
|
||||
"role": participant["role"],
|
||||
"company_id": participant["company_id"],
|
||||
"bank_account_id": participant["bank_account_id"],
|
||||
"resolve_method": participant["resolve_method"],
|
||||
"evidence": json.loads(participant["evidence"])
|
||||
if participant["evidence"]
|
||||
else None,
|
||||
}
|
||||
for participant in participants
|
||||
],
|
||||
"observations": observation_rows,
|
||||
"candidates": [
|
||||
{
|
||||
"source_row_id": candidate["source_row_id"],
|
||||
"rule_tier": candidate["rule_tier"],
|
||||
"date_diff_days": candidate["date_diff_days"],
|
||||
"account_mirror": bool(candidate["account_mirror"]),
|
||||
"reference_match": candidate["reference_match"],
|
||||
"summary_match": bool(candidate["summary_match"]),
|
||||
"accepted": candidate["accepted"],
|
||||
"reason": candidate["reason"],
|
||||
}
|
||||
for candidate in candidates
|
||||
],
|
||||
"history": history,
|
||||
}
|
||||
|
||||
def _handle_admin_match_exceptions(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
conditions = ["d.classification IN ('unresolved', 'needs_review')"]
|
||||
params: list[object] = []
|
||||
raw_company = (query.get("company_id") or [None])[0]
|
||||
if raw_company:
|
||||
try:
|
||||
company_id = int(raw_company)
|
||||
except ValueError:
|
||||
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
||||
return
|
||||
conditions.append("(payer.company_id = ? OR payee.company_id = ?)")
|
||||
params.extend([company_id, company_id])
|
||||
rows = connection.execute(
|
||||
self._event_base_sql(False)
|
||||
+ f"WHERE {' AND '.join(conditions)} ORDER BY d.id DESC LIMIT 500",
|
||||
params,
|
||||
).fetchall()
|
||||
items = [self._admin_event_list_item(row) for row in rows]
|
||||
self._send_json(200, {"status": "ok", "exceptions": items})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reconcile(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
raw_rows = data.get("source_row_ids")
|
||||
row_ids: list[int] = []
|
||||
if raw_rows is not None:
|
||||
if not isinstance(raw_rows, list):
|
||||
self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"})
|
||||
return
|
||||
try:
|
||||
row_ids = [int(item) for item in raw_rows]
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "source_row_ids 必须是整数数组。"})
|
||||
return
|
||||
raw_batch = data.get("batch_id")
|
||||
if raw_batch is not None and not row_ids:
|
||||
try:
|
||||
batch_id = int(raw_batch)
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "batch_id 参数无效。"})
|
||||
return
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT r.id FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
|
||||
WHERE s.import_batch_id = ? AND rv.review_status = 'confirmed'
|
||||
ORDER BY r.id
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchall()
|
||||
row_ids = [item["id"] for item in rows]
|
||||
if not row_ids:
|
||||
self._send_json(400, {"status": "error", "message": "必须指定 source_row_ids 或 batch_id。"})
|
||||
return
|
||||
try:
|
||||
result = matching.reconcile_rows(connection, row_ids, actor=user)
|
||||
except Exception as exc:
|
||||
self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{exc}"})
|
||||
return
|
||||
auth.audit(
|
||||
connection, "transfer_reconcile", actor=user,
|
||||
target=f"rows:{len(row_ids)}",
|
||||
detail=f"created:{result['created_events']};updated:{result['updated_events']}",
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "matching": result})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_transfer_decision(self, event_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
action = str(data.get("action") or "")
|
||||
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
|
||||
source_row_ids = data.get("source_row_ids")
|
||||
if source_row_ids is not None and not isinstance(source_row_ids, list):
|
||||
self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"})
|
||||
return
|
||||
try:
|
||||
payload = matching.apply_manual_decision(
|
||||
connection,
|
||||
event_id,
|
||||
action,
|
||||
reason=str(data.get("reason") or ""),
|
||||
expected_revision=expected_revision,
|
||||
request_key=str(data.get("request_key") or "") or None,
|
||||
actor=user,
|
||||
source_row_ids=[int(item) for item in source_row_ids]
|
||||
if source_row_ids
|
||||
else None,
|
||||
participant=data.get("participant"),
|
||||
)
|
||||
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
|
||||
self._send_json(200, {"status": "ok", "decision": payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Personal transit mappings (admin)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_admin_personal_mappings(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
raw_company = (query.get("company_id") or [None])[0]
|
||||
raw_status = (query.get("status") or [None])[0]
|
||||
try:
|
||||
company_id = int(raw_company) if raw_company else None
|
||||
except ValueError:
|
||||
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
||||
return
|
||||
try:
|
||||
rows = personal_transit.list_mappings(
|
||||
connection, company_id=company_id, status=raw_status or None
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(
|
||||
200,
|
||||
{"status": "ok",
|
||||
"mappings": [personal_transit.mapping_payload(row, full=True) for row in rows]},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_create_personal_mapping(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
try:
|
||||
company_id = int(str(data.get("represented_company_id")))
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "represented_company_id 参数无效。"})
|
||||
return
|
||||
try:
|
||||
mapping = personal_transit.submit_mapping(
|
||||
connection,
|
||||
account_number=data.get("account_number"),
|
||||
account_name=data.get("account_name"),
|
||||
represented_company_id=company_id,
|
||||
allowed_direction=str(data.get("allowed_direction") or ""),
|
||||
effective_from=data.get("effective_from"),
|
||||
actor=user,
|
||||
)
|
||||
except personal_transit.ConflictError as exc:
|
||||
self._send_json(409, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
auth.audit(
|
||||
connection, "personal_transit_submit", actor=user,
|
||||
target=f"personal_transit:{mapping['id']}",
|
||||
detail=f"company:{company_id}",
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(
|
||||
200,
|
||||
{"status": "ok",
|
||||
"mapping": personal_transit.mapping_payload(mapping, full=True)},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_review_personal_mapping(self, mapping_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
try:
|
||||
mapping = personal_transit.review_mapping(
|
||||
connection,
|
||||
mapping_id,
|
||||
str(data.get("decision") or ""),
|
||||
str(data.get("reason") or ""),
|
||||
user,
|
||||
effective_from=data.get("effective_from"),
|
||||
effective_to=data.get("effective_to"),
|
||||
)
|
||||
except LookupError as exc:
|
||||
self._send_json(404, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except personal_transit.ConflictError as exc:
|
||||
self._send_json(409, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
auth.audit(
|
||||
connection, "personal_transit_review", actor=user,
|
||||
target=f"personal_transit:{mapping_id}",
|
||||
detail=str(data.get("decision")),
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(
|
||||
200,
|
||||
{"status": "ok",
|
||||
"mapping": personal_transit.mapping_payload(mapping, full=True)},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Canonical transfer events (company, own-company scope only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_company_transfer_events(self, query: dict[str, list[str]]) -> 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
|
||||
conditions = []
|
||||
params: list[object] = [user["company_id"], user["company_id"]]
|
||||
status = (query.get("status") or [None])[0]
|
||||
raw_limit = (query.get("limit") or ["200"])[0]
|
||||
try:
|
||||
limit = max(1, min(int(raw_limit), 500))
|
||||
except ValueError:
|
||||
limit = 200
|
||||
rows = connection.execute(
|
||||
self._event_base_sql(True) + " ORDER BY d.id DESC LIMIT ?",
|
||||
(*params, limit),
|
||||
).fetchall()
|
||||
items = [
|
||||
self._company_event_list_item(row, user["company_id"]) for row in rows
|
||||
]
|
||||
if status:
|
||||
items = [item for item in items if item["status"] == status]
|
||||
self._send_json(200, {"status": "ok", "events": items})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _company_event_list_item(row, own_company_id: int) -> dict[str, object]:
|
||||
counterparty_company_id = (
|
||||
row["payee_company_id"]
|
||||
if row["payer_company_id"] == own_company_id
|
||||
else row["payer_company_id"]
|
||||
)
|
||||
counterparty_company_name = (
|
||||
row["payee_company_name"]
|
||||
if row["payer_company_id"] == own_company_id
|
||||
else row["payer_company_name"]
|
||||
)
|
||||
return {
|
||||
"event_id": row["event_id"],
|
||||
"classification": row["classification"],
|
||||
"pairing": row["pairing"],
|
||||
"status": matching.exposed_status(row),
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"effective_at": row["effective_at"],
|
||||
"mode": row["mode"],
|
||||
"rule_version": row["rule_version"],
|
||||
"own_company_id": own_company_id,
|
||||
"counterparty_company_id": counterparty_company_id,
|
||||
"counterparty_company_name": counterparty_company_name,
|
||||
"evidence_count": row["evidence_count"],
|
||||
}
|
||||
|
||||
def _handle_company_match_exceptions(self, query: dict[str, list[str]]) -> 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
|
||||
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()
|
||||
items = [
|
||||
self._company_event_list_item(row, user["company_id"]) for row in rows
|
||||
]
|
||||
self._send_json(200, {"status": "ok", "exceptions": items})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_transfer_event_detail(self, event_id: int) -> 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
|
||||
current = matching._current_decision_for_event(connection, event_id)
|
||||
if current is None:
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
participants = matching._decision_participants(connection, current["id"])
|
||||
participant_company_ids = {p["company_id"] for p in participants}
|
||||
if user["company_id"] not in participant_company_ids:
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
observations = matching._decision_observations(connection, current["id"])
|
||||
own_rows = self._company_observation_rows(connection, observations, user["company_id"])
|
||||
other_side = next(
|
||||
(
|
||||
p for p in participants
|
||||
if p["company_id"] != user["company_id"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
counterparty = None
|
||||
if other_side is not None:
|
||||
company = connection.execute(
|
||||
"SELECT name FROM companies WHERE id = ?", (other_side["company_id"],)
|
||||
).fetchone()
|
||||
counterparty = {
|
||||
"company_id": other_side["company_id"],
|
||||
"company_name": company["name"] if company else None,
|
||||
}
|
||||
if other_side["bank_account_id"] is not None:
|
||||
account = master_data.get_account(connection, other_side["bank_account_id"])
|
||||
if account is not None:
|
||||
counterparty["account_number_masked"] = master_data.mask_account_number(
|
||||
account["account_number"]
|
||||
)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"event": {
|
||||
"event_id": event_id,
|
||||
"classification": current["classification"],
|
||||
"pairing": current["pairing"],
|
||||
"status": matching.exposed_status(current),
|
||||
"amount": current["amount"],
|
||||
"currency": current["currency"],
|
||||
"effective_at": current["effective_at"],
|
||||
"mode": current["mode"],
|
||||
"reason": current["reason"],
|
||||
"counterparty": counterparty,
|
||||
"observations": own_rows,
|
||||
},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _company_observation_rows(self, connection, observations, own_company_id: int) -> list[dict]:
|
||||
rows = []
|
||||
for observation in observations:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
|
||||
r.own_account, r.own_name, r.counterparty_account,
|
||||
r.counterparty_name, r.summary, r.reference,
|
||||
s.sheet_name, b.company_id AS batch_company_id
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN import_batches b ON b.id = s.import_batch_id
|
||||
WHERE r.id = ?
|
||||
""",
|
||||
(observation["source_row_id"],),
|
||||
).fetchone()
|
||||
if row is None or row["batch_company_id"] != own_company_id:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"source_row_id": row["id"],
|
||||
"role": observation["role"],
|
||||
"source_row": row["source_row"],
|
||||
"sheet_name": row["sheet_name"],
|
||||
"transaction_at": row["transaction_at"],
|
||||
"income": row["income"],
|
||||
"expense": row["expense"],
|
||||
"batch_company_id": row["batch_company_id"],
|
||||
"own_account_masked": master_data.mask_account_number(row["own_account"])
|
||||
if row["own_account"]
|
||||
else None,
|
||||
"counterparty_name": row["counterparty_name"],
|
||||
"summary": row["summary"],
|
||||
"reference": row["reference"],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Request/response plumbing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user