B-44: intercompany ledger events, subject review and drill-down evidence
- migration 6: manual_records, ledger_event_revisions chain, current projections, source claims, subject suggestions, eligible_position_events - ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen, append-only revision chain and rebuildable current projection - subjects.py: fixed subject mirror, draft suggestion dictionary, explicit administrator subject confirmation with expected_revision + idempotency - manual_records.py: submit, approve new/link, return/exception/reverse, candidate hints, idempotent replay and concurrency-safe claims - positions.py: Decimal aggregation, both-perspective conservation asserts, cutoff window, unresolved gross buckets, keyset pagination, evidence visibility (visible/masked/missing) - server.py: admin + company intercompany APIs with tenant isolation (404 on cross-tenant reads, 403 on company writes) and auto reconcile wiring - admin/company portals: balance directory, pair drill-down drawer, evidence drawer, subject/manual audit queue, company balance summary - tests: ledger events, subjects, manual records, positions, HTTP API and migration persistence (233 total, all green)
This commit is contained in:
@@ -10,7 +10,10 @@ 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, matching, multipart, personal_transit
|
||||
from bank_importer import (
|
||||
auth, importing, ledger_events, manual_records, master_data, matching,
|
||||
multipart, personal_transit, positions, subjects,
|
||||
)
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
@@ -101,6 +104,68 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
|
||||
return
|
||||
|
||||
# B-44 intercompany positions (admin)
|
||||
if path == "/api/admin/intercompany/balances":
|
||||
self._handle_admin_intercompany_balances(query)
|
||||
return
|
||||
admin_pair = re.fullmatch(
|
||||
r"/api/admin/intercompany/pairs/(\d+)/(\d+)", path
|
||||
)
|
||||
if admin_pair:
|
||||
self._handle_admin_intercompany_pair(
|
||||
int(admin_pair.group(1)), int(admin_pair.group(2)), query
|
||||
)
|
||||
return
|
||||
if path == "/api/admin/intercompany/events":
|
||||
self._handle_admin_intercompany_events(query)
|
||||
return
|
||||
if path == "/api/admin/subject-reviews":
|
||||
self._handle_admin_subject_reviews(query)
|
||||
return
|
||||
if path == "/api/admin/manual-records":
|
||||
self._handle_admin_manual_records(query)
|
||||
return
|
||||
admin_event_match = re.fullmatch(r"/api/admin/intercompany/events/(\d+)", path)
|
||||
if admin_event_match:
|
||||
self._handle_admin_intercompany_event_detail(int(admin_event_match.group(1)))
|
||||
return
|
||||
admin_evidence = re.fullmatch(
|
||||
r"/api/admin/intercompany/events/(\d+)/evidence", path
|
||||
)
|
||||
if admin_evidence:
|
||||
self._handle_admin_intercompany_evidence(int(admin_evidence.group(1)))
|
||||
return
|
||||
|
||||
# B-44 intercompany positions (company, own-company scope)
|
||||
if path == "/api/company/intercompany/balances":
|
||||
self._handle_company_intercompany_balances(query)
|
||||
return
|
||||
company_pair = re.fullmatch(r"/api/company/intercompany/pairs/(\d+)", path)
|
||||
if company_pair:
|
||||
self._handle_company_intercompany_pair(int(company_pair.group(1)), query)
|
||||
return
|
||||
if path == "/api/company/intercompany/events":
|
||||
self._handle_company_intercompany_events(query)
|
||||
return
|
||||
if path == "/api/company/manual-records":
|
||||
self._handle_company_manual_records(query)
|
||||
return
|
||||
if path == "/api/company/companies":
|
||||
self._handle_company_companies()
|
||||
return
|
||||
company_event_match = re.fullmatch(r"/api/company/intercompany/events/(\d+)", path)
|
||||
if company_event_match:
|
||||
self._handle_company_intercompany_event_detail(
|
||||
int(company_event_match.group(1))
|
||||
)
|
||||
return
|
||||
company_evidence = re.fullmatch(
|
||||
r"/api/company/intercompany/events/(\d+)/evidence", path
|
||||
)
|
||||
if company_evidence:
|
||||
self._handle_company_intercompany_evidence(int(company_evidence.group(1)))
|
||||
return
|
||||
|
||||
if path == "/admin.html" and not self._guard_page("admin"):
|
||||
return
|
||||
if path == "/company.html" and not self._guard_page("company"):
|
||||
@@ -167,6 +232,29 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if mapping_review:
|
||||
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
|
||||
return
|
||||
|
||||
# B-44 intercompany positions (admin writes)
|
||||
subject_decision = re.fullmatch(
|
||||
r"/api/admin/intercompany/events/(\d+)/subject-decisions", path
|
||||
)
|
||||
if subject_decision:
|
||||
self._handle_admin_subject_decision(int(subject_decision.group(1)))
|
||||
return
|
||||
adjustment = re.fullmatch(
|
||||
r"/api/admin/intercompany/events/(\d+)/adjustments", path
|
||||
)
|
||||
if adjustment:
|
||||
self._handle_admin_intercompany_adjustment(int(adjustment.group(1)))
|
||||
return
|
||||
manual_decision = re.fullmatch(r"/api/admin/manual-records/(\d+)/decisions", path)
|
||||
if manual_decision:
|
||||
self._handle_admin_manual_record_decision(int(manual_decision.group(1)))
|
||||
return
|
||||
|
||||
# B-44 intercompany positions (company writes)
|
||||
if path == "/api/company/manual-records":
|
||||
self._handle_company_manual_records_submit()
|
||||
return
|
||||
self._send_json(404, {"status": "error", "message": "接口不存在。"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1611,6 +1699,11 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
except Exception as exc:
|
||||
self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{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
|
||||
auth.audit(
|
||||
connection, "transfer_reconcile", actor=user,
|
||||
target=f"rows:{len(row_ids)}",
|
||||
@@ -1664,6 +1757,11 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
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
|
||||
self._send_json(200, {"status": "ok", "decision": payload})
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -1974,6 +2072,721 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
return rows
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# B-44 intercompany positions (admin)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_window(self, query: dict[str, list[str]]) -> tuple[str, str]:
|
||||
raw_from = (query.get("from") or [None])[0]
|
||||
raw_cutoff = (query.get("cutoff") or [None])[0]
|
||||
if raw_cutoff is None:
|
||||
raw_cutoff = positions.today_shanghai()
|
||||
try:
|
||||
return positions.validate_window(raw_from, raw_cutoff)
|
||||
except positions.PositionInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
raise
|
||||
|
||||
def _parse_limit(self, query: dict[str, list[str]], default: int = 50) -> int:
|
||||
raw = (query.get("limit") or [str(default)])[0]
|
||||
try:
|
||||
return max(1, min(int(raw), 200))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def _parse_int(self, query: dict[str, list[str]], key: str, label: str) -> int | None:
|
||||
raw = (query.get(key) or [None])[0]
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
self._send_json(400, {"status": "error", "message": f"{label}参数无效。"})
|
||||
raise
|
||||
|
||||
def _handle_admin_intercompany_balances(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
try:
|
||||
from_, cutoff = self._parse_window(query)
|
||||
company_id = self._parse_int(query, "company_id", "company_id")
|
||||
currency = (query.get("currency") or [None])[0]
|
||||
limit = self._parse_limit(query)
|
||||
cursor = (query.get("cursor") or [None])[0]
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
payload = positions.company_balances(
|
||||
connection, from_=from_, cutoff=cutoff, currency=currency,
|
||||
company_id=company_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
except positions.PositionInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_intercompany_pair(
|
||||
self, company_a: int, company_b: int, query: dict[str, list[str]]
|
||||
) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
if not self._companies_exist(connection, (company_a, company_b)):
|
||||
self._send_json(404, {"status": "error", "message": "公司不存在。"})
|
||||
return
|
||||
try:
|
||||
from_, cutoff = self._parse_window(query)
|
||||
currency = (query.get("currency") or [None])[0]
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
payload = positions.pair_detail(
|
||||
connection, company_a, company_b,
|
||||
from_=from_, cutoff=cutoff, currency=currency,
|
||||
)
|
||||
except positions.PositionError as exc:
|
||||
self._send_json(422, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except positions.PositionInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _companies_exist(self, connection, ids: tuple[int, ...]) -> bool:
|
||||
for company_id in ids:
|
||||
row = connection.execute(
|
||||
"SELECT id FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _handle_admin_intercompany_events(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
try:
|
||||
from_, cutoff = self._parse_window(query)
|
||||
currency = (query.get("currency") or [None])[0]
|
||||
company_id = self._parse_int(query, "company_id", "company_id")
|
||||
company_a = self._parse_int(query, "company_a", "company_a")
|
||||
company_b = self._parse_int(query, "company_b", "company_b")
|
||||
subject = (query.get("subject") or [None])[0]
|
||||
state = (query.get("state") or [None])[0]
|
||||
posting_kind = (query.get("posting_kind") or [None])[0]
|
||||
source_kind = (query.get("source_kind") or [None])[0]
|
||||
limit = self._parse_limit(query)
|
||||
cursor = (query.get("cursor") or [None])[0]
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
payload = positions.list_events(
|
||||
connection, from_=from_, cutoff=cutoff, currency=currency,
|
||||
company_id=company_id, company_a=company_a, company_b=company_b,
|
||||
subject=subject, state=state, posting_kind=posting_kind,
|
||||
source_kind=source_kind, limit=limit, cursor=cursor,
|
||||
)
|
||||
except positions.PositionInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_subject_reviews(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
try:
|
||||
from_, cutoff = self._parse_window(query)
|
||||
company_id = self._parse_int(query, "company_id", "company_id")
|
||||
limit = self._parse_limit(query)
|
||||
cursor = (query.get("cursor") or [None])[0]
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
payload = positions.subject_review_queue(
|
||||
connection, from_=from_, cutoff=cutoff,
|
||||
company_id=company_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
except positions.PositionInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_manual_records(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
company_id = self._parse_int(query, "company_id", "company_id")
|
||||
if company_id is None and (query.get("company_id") or [None])[0]:
|
||||
return
|
||||
state = (query.get("state") or [None])[0]
|
||||
limit = self._parse_limit(query, 100)
|
||||
try:
|
||||
rows = manual_records.list_records(
|
||||
connection, company_id=company_id, state=state, limit=limit
|
||||
)
|
||||
except manual_records.ManualInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
items = []
|
||||
for row in rows:
|
||||
payload = manual_records._row_payload(connection, row)
|
||||
items.append(payload)
|
||||
self._send_json(200, {"status": "ok", "records": items})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_intercompany_event_detail(self, event_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
payload = positions.event_detail(connection, event_id)
|
||||
if payload is None:
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_intercompany_evidence(self, event_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
payload = positions.event_evidence(connection, event_id)
|
||||
if payload is None:
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_subject_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
|
||||
try:
|
||||
perspective_company_id = int(data["perspective_company_id"])
|
||||
expected_revision = (
|
||||
int(data["expected_revision"])
|
||||
if data.get("expected_revision") is not None
|
||||
else None
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
self._send_json(
|
||||
400, {"status": "error", "message": "perspective_company_id 或 expected_revision 无效。"}
|
||||
)
|
||||
return
|
||||
try:
|
||||
payload = subjects.confirm_subject(
|
||||
connection,
|
||||
event_id,
|
||||
perspective_company_id=perspective_company_id,
|
||||
subject_code=str(data.get("subject_code") or ""),
|
||||
reason=str(data.get("reason") or ""),
|
||||
expected_revision=expected_revision,
|
||||
request_key=str(data.get("request_key") or "") or None,
|
||||
actor=user,
|
||||
)
|
||||
except subjects.SubjectConflictError as exc:
|
||||
self._send_json(409, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except subjects.SubjectInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
auth.audit(
|
||||
connection, "subject_confirm", actor=user,
|
||||
target=f"ledger_event:{event_id}",
|
||||
detail=f"revision:{payload['revision']};subject:{payload['subject_code']}",
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "revision": payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_intercompany_adjustment(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 "")
|
||||
reason = str(data.get("reason") or "")
|
||||
request_key = str(data.get("request_key") or "") or None
|
||||
if not reason:
|
||||
self._send_json(400, {"status": "error", "message": "必须填写操作原因。"})
|
||||
return
|
||||
try:
|
||||
if action == "reverse":
|
||||
event_id, _revision_id = ledger_events.create_reversal(
|
||||
connection, event_id,
|
||||
source_kind="adjustment",
|
||||
source_revision_token=None,
|
||||
effective_at=data.get("effective_at") or None,
|
||||
reason=reason, actor=user, idempotency_key=request_key,
|
||||
)
|
||||
outcome: dict[str, object] = {
|
||||
"action": "reverse", "ledger_event_id": event_id,
|
||||
}
|
||||
elif action == "adjust":
|
||||
try:
|
||||
effective_at = str(data.get("effective_at") or "")
|
||||
amount = str(data.get("amount") or "")
|
||||
currency = str(data.get("currency") or "")
|
||||
payer = int(data["payer_company_id"])
|
||||
payee = int(data["payee_company_id"])
|
||||
perspective = int(data["perspective_company_id"])
|
||||
subject_code = str(data.get("subject_code") or "")
|
||||
except (KeyError, TypeError, ValueError):
|
||||
self._send_json(
|
||||
400, {"status": "error", "message": "adjust 参数不完整或无效。"}
|
||||
)
|
||||
return
|
||||
event_id, _revision_id = ledger_events.create_adjustment(
|
||||
connection, event_id,
|
||||
effective_at=effective_at, amount=amount, currency=currency,
|
||||
payer_company_id=payer, payee_company_id=payee,
|
||||
perspective_company_id=perspective, subject_code=subject_code,
|
||||
reason=reason, actor=user, idempotency_key=request_key,
|
||||
)
|
||||
outcome = {"action": "adjust", "ledger_event_id": event_id}
|
||||
elif action == "reopen":
|
||||
event_id, _revision_id = ledger_events.reopen_subject(
|
||||
connection, event_id, reason=reason, actor=user,
|
||||
idempotency_key=request_key,
|
||||
)
|
||||
outcome = {"action": "reopen", "ledger_event_id": event_id}
|
||||
else:
|
||||
self._send_json(
|
||||
400,
|
||||
{"status": "error", "message": "action 必须是 reverse、adjust 或 reopen。"},
|
||||
)
|
||||
return
|
||||
except ledger_events.LedgerConflictError as exc:
|
||||
self._send_json(409, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except ledger_events.LedgerInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
auth.audit(
|
||||
connection, f"ledger_{action}", actor=user,
|
||||
target=f"ledger_event:{event_id}", detail=reason, ip=self._client_ip,
|
||||
)
|
||||
self._send_json(200, {"status": "ok", **outcome})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_manual_record_decision(self, record_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:
|
||||
expected_decision_id = (
|
||||
int(data["expected_decision_id"])
|
||||
if data.get("expected_decision_id") is not None
|
||||
else None
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "expected_decision_id 无效。"})
|
||||
return
|
||||
try:
|
||||
payload = manual_records.decide(
|
||||
connection,
|
||||
record_id,
|
||||
str(data.get("action") or ""),
|
||||
reason=str(data.get("reason") or ""),
|
||||
expected_decision_id=expected_decision_id,
|
||||
request_key=str(data.get("request_key") or "") or None,
|
||||
actor=user,
|
||||
subject_code=data.get("subject_code"),
|
||||
target_ledger_event_id=data.get("target_ledger_event_id"),
|
||||
)
|
||||
except manual_records.ManualConflictError as exc:
|
||||
self._send_json(409, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except manual_records.ManualInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "decision": payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# B-44 intercompany positions (company, own-company scope only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _company_intercompany_scope(self, connection):
|
||||
"""Reject company users with 403; return the session company id."""
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return None, None
|
||||
if user["role"] != "company":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
||||
return None, None
|
||||
return user, user["company_id"]
|
||||
|
||||
def _handle_company_intercompany_balances(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user, company_id = self._company_intercompany_scope(connection)
|
||||
if company_id is None:
|
||||
return
|
||||
try:
|
||||
from_, cutoff = self._parse_window(query)
|
||||
currency = (query.get("currency") or [None])[0]
|
||||
limit = self._parse_limit(query)
|
||||
cursor = (query.get("cursor") or [None])[0]
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
payload = positions.company_balances(
|
||||
connection, from_=from_, cutoff=cutoff, currency=currency,
|
||||
company_id=company_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
counterparties = self._company_counterparty_summary(
|
||||
connection, company_id, from_, cutoff, currency
|
||||
)
|
||||
except positions.PositionInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload, "counterparties": counterparties})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _company_counterparty_summary(
|
||||
self, connection, company_id: int, from_: str, cutoff: str, currency: str | None
|
||||
) -> list[dict[str, object]]:
|
||||
events = positions.load_events(
|
||||
connection, from_=from_, cutoff=cutoff, currency=currency,
|
||||
company_id=company_id,
|
||||
)
|
||||
buckets: dict[int, dict[str, object]] = {}
|
||||
for event in events:
|
||||
counterparty = (
|
||||
event["payee_company_id"]
|
||||
if int(event["payer_company_id"]) == int(company_id)
|
||||
else event["payer_company_id"]
|
||||
)
|
||||
name = (
|
||||
event["payee_company_name"]
|
||||
if int(event["payer_company_id"]) == int(company_id)
|
||||
else event["payer_company_name"]
|
||||
)
|
||||
bucket = buckets.setdefault(
|
||||
counterparty,
|
||||
{
|
||||
"counterparty_company_id": counterparty,
|
||||
"counterparty_company_name": name,
|
||||
"currency": event["currency"],
|
||||
"signed": 0,
|
||||
"event_count": 0,
|
||||
},
|
||||
)
|
||||
bucket["signed"] += positions.signed_amount(event, company_id)
|
||||
bucket["event_count"] += 1
|
||||
|
||||
# Counterparties with pending-subject exposure also show up so the
|
||||
# company portal never hides unconfirmed balances.
|
||||
pending = connection.execute(
|
||||
"""
|
||||
SELECT p.payer_company_id, p.payee_company_id, p.amount, p.currency,
|
||||
cpayer.name AS payer_company_name, cpayee.name AS payee_company_name
|
||||
FROM current_ledger_event_revisions cur
|
||||
JOIN ledger_event_revisions p ON p.id = cur.revision_id
|
||||
JOIN companies cpayer ON cpayer.id = p.payer_company_id
|
||||
JOIN companies cpayee ON cpayee.id = p.payee_company_id
|
||||
WHERE p.state = 'pending_subject'
|
||||
AND p.effective_at >= ? AND p.effective_at <= ?
|
||||
AND (p.payer_company_id = ? OR p.payee_company_id = ?)
|
||||
""",
|
||||
(from_, cutoff + "T23:59:59", company_id, company_id),
|
||||
).fetchall()
|
||||
for row in pending:
|
||||
if currency and row["currency"] != currency:
|
||||
continue
|
||||
counterparty = (
|
||||
row["payee_company_id"]
|
||||
if int(row["payer_company_id"]) == int(company_id)
|
||||
else row["payer_company_id"]
|
||||
)
|
||||
name = (
|
||||
row["payee_company_name"]
|
||||
if int(row["payer_company_id"]) == int(company_id)
|
||||
else row["payer_company_name"]
|
||||
)
|
||||
buckets.setdefault(
|
||||
counterparty,
|
||||
{
|
||||
"counterparty_company_id": counterparty,
|
||||
"counterparty_company_name": name,
|
||||
"currency": row["currency"],
|
||||
"signed": 0,
|
||||
"event_count": 0,
|
||||
},
|
||||
)
|
||||
items = []
|
||||
for counterparty, bucket in sorted(buckets.items()):
|
||||
cur = bucket["currency"]
|
||||
signed = bucket["signed"]
|
||||
unresolved = positions.unresolved_for_company(
|
||||
connection, company_id, cutoff, currency=cur,
|
||||
counterparty_filter=(company_id, counterparty),
|
||||
)
|
||||
direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None)
|
||||
items.append(
|
||||
{
|
||||
"counterparty_company_id": counterparty,
|
||||
"counterparty_company_name": bucket["counterparty_company_name"],
|
||||
"currency": cur,
|
||||
"result": {
|
||||
"kind": "period_net_change",
|
||||
"signed_amount": str(signed),
|
||||
"direction": direction,
|
||||
"label": "期间净变动",
|
||||
},
|
||||
"unresolved": unresolved,
|
||||
"event_count": bucket["event_count"],
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
def _handle_company_intercompany_pair(
|
||||
self, counterparty_id: int, query: dict[str, list[str]]
|
||||
) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user, company_id = self._company_intercompany_scope(connection)
|
||||
if company_id is None:
|
||||
return
|
||||
if not self._companies_exist(connection, (counterparty_id,)):
|
||||
self._send_json(404, {"status": "error", "message": "公司不存在。"})
|
||||
return
|
||||
try:
|
||||
from_, cutoff = self._parse_window(query)
|
||||
currency = (query.get("currency") or [None])[0]
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
payload = positions.pair_detail(
|
||||
connection, company_id, counterparty_id,
|
||||
from_=from_, cutoff=cutoff, currency=currency,
|
||||
)
|
||||
except positions.PositionError as exc:
|
||||
self._send_json(422, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except positions.PositionInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_intercompany_events(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user, company_id = self._company_intercompany_scope(connection)
|
||||
if company_id is None:
|
||||
return
|
||||
try:
|
||||
from_, cutoff = self._parse_window(query)
|
||||
currency = (query.get("currency") or [None])[0]
|
||||
subject = (query.get("subject") or [None])[0]
|
||||
state = (query.get("state") or [None])[0]
|
||||
posting_kind = (query.get("posting_kind") or [None])[0]
|
||||
source_kind = (query.get("source_kind") or [None])[0]
|
||||
limit = self._parse_limit(query)
|
||||
cursor = (query.get("cursor") or [None])[0]
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
payload = positions.list_events(
|
||||
connection, from_=from_, cutoff=cutoff, currency=currency,
|
||||
subject=subject, state=state, posting_kind=posting_kind,
|
||||
source_kind=source_kind, viewer_company_id=company_id,
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
except positions.PositionInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_intercompany_event_detail(self, event_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user, company_id = self._company_intercompany_scope(connection)
|
||||
if company_id is None:
|
||||
return
|
||||
payload = positions.event_detail(connection, event_id)
|
||||
if payload is None:
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
event = payload["event"]
|
||||
if company_id not in (event["payer_company_id"], event["payee_company_id"]):
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
event.update(
|
||||
positions.event_payload(
|
||||
connection,
|
||||
connection.execute(
|
||||
positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone(),
|
||||
viewer_company_id=company_id,
|
||||
)
|
||||
)
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_intercompany_evidence(self, event_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user, company_id = self._company_intercompany_scope(connection)
|
||||
if company_id is None:
|
||||
return
|
||||
detail = positions.event_detail(connection, event_id)
|
||||
if detail is None:
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
event = detail["event"]
|
||||
if company_id not in (event["payer_company_id"], event["payee_company_id"]):
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
payload = positions.event_evidence(
|
||||
connection, event_id, viewer_company_id=company_id
|
||||
)
|
||||
if payload is None:
|
||||
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", **payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_companies(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user, company_id = self._company_intercompany_scope(connection)
|
||||
if company_id is None:
|
||||
return
|
||||
rows = connection.execute(
|
||||
"SELECT id, name FROM companies ORDER BY id"
|
||||
).fetchall()
|
||||
self._send_json(
|
||||
200,
|
||||
{"status": "ok", "companies": [dict(row) for row in rows]},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_manual_records(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user, company_id = self._company_intercompany_scope(connection)
|
||||
if company_id is None:
|
||||
return
|
||||
state = (query.get("state") or [None])[0]
|
||||
limit = self._parse_limit(query, 100)
|
||||
try:
|
||||
rows = manual_records.list_records(
|
||||
connection, company_id=company_id, state=state, limit=limit
|
||||
)
|
||||
except manual_records.ManualInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
# A company only ever sees its own submissions, never declarations
|
||||
# where it is merely the counterparty.
|
||||
own = [
|
||||
manual_records._row_payload(connection, row)
|
||||
for row in rows
|
||||
if row["company_id"] == company_id
|
||||
]
|
||||
self._send_json(200, {"status": "ok", "records": own})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_manual_records_submit(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user, company_id = self._company_intercompany_scope(connection)
|
||||
if company_id is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
try:
|
||||
payload = manual_records.submit(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
counterparty_company_id=int(data["counterparty_company_id"]),
|
||||
occurred_at=str(data.get("occurred_at") or ""),
|
||||
direction=str(data.get("direction") or ""),
|
||||
amount=str(data.get("amount") or ""),
|
||||
currency=str(data.get("currency") or ""),
|
||||
funding_source=str(data.get("funding_source") or ""),
|
||||
requested_subject=str(data.get("requested_subject") or ""),
|
||||
request_key=str(data.get("request_key") or ""),
|
||||
actor=user,
|
||||
bank_account_id=data.get("bank_account_id"),
|
||||
personal_transit_mapping_id=data.get("personal_transit_mapping_id"),
|
||||
related_source_row_id=data.get("related_source_row_id"),
|
||||
summary=data.get("summary"),
|
||||
reason=data.get("reason"),
|
||||
evidence=data.get("evidence"),
|
||||
)
|
||||
except manual_records.ManualConflictError as exc:
|
||||
self._send_json(409, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except manual_records.ManualInputError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
auth.audit(
|
||||
connection, "manual_submit", actor=user,
|
||||
target=f"manual_record:{payload['id']}",
|
||||
detail=f"amount:{payload['amount']};currency:{payload['currency']}",
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "record": payload})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Request/response plumbing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user