"""Core 模拟库只读访问(jinrong_core · 无 HTTP API)。""" from __future__ import annotations from datetime import date from typing import Any, Literal from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine from app.config.settings import settings MatchResult = Literal["allowed", "allowed_with_disclosure", "forbidden", "professional_exempt", "risk_expired"] class CoreReadOnlyRepository: """仅 SELECT jinrong_core;禁止写操作。""" def __init__(self, engine: Engine | None = None) -> None: self._engine = engine or self._default_engine() @staticmethod def _default_engine() -> Engine: pwd = settings.mysql_password auth = f"{settings.mysql_user}:{pwd}" if pwd else settings.mysql_user url = ( f"mysql+pymysql://{auth}@{settings.mysql_host}:{settings.mysql_port}" f"/{settings.mysql_core_database}?charset=utf8mb4" ) return create_engine(url, pool_pre_ping=True) def get_customer_l0(self, customer_id: str) -> dict[str, Any] | None: """L0 客户主档 + 正式风评(对齐用户信息数据示例 / 适当性指南)。""" sql = text( """ SELECT c.*, r.risk_code, r.questionnaire_score, r.max_loss_tolerance_pct, r.investment_goal, r.investment_horizon, r.investor_category, r.professional_approved_at, r.evaluated_at AS risk_evaluated_at, r.expires_at AS risk_expires_at, (r.expires_at < CURDATE()) AS risk_is_expired FROM core_customer c LEFT JOIN core_customer_risk r ON r.customer_id = c.customer_id WHERE c.customer_id = :cid AND c.is_active = 1 """ ) with self._engine.connect() as conn: row = conn.execute(sql, {"cid": customer_id}).mappings().first() return dict(row) if row else None def check_suitability(self, customer_id: str, product_id: str) -> dict[str, Any]: """R-02:基于 L0 C 等级 + 产品 R 等级 + 适当性矩阵判定。 返回字段与 risk_suitability_log 一一对应,见 docs/项目框架设计/表设计/07-risk_suitability_log说明.md """ sql = text( """ SELECT c.customer_id, c.age, c.is_hnw, r.risk_code AS customer_risk_code, r.investor_category, r.expires_at AS risk_expires_at, (r.expires_at < CURDATE()) AS risk_is_expired, p.product_id, p.product_name, p.min_risk_code AS product_risk_code, p.min_subscribe_amount, p.term_days, p.requires_disclosure AS product_requires_disclosure, sr.match_result AS matrix_match_result FROM core_customer c JOIN core_customer_risk r ON r.customer_id = c.customer_id JOIN core_product p ON p.product_id = :pid LEFT JOIN core_suitability_rule sr ON sr.customer_risk_code = r.risk_code AND sr.product_risk_code = p.min_risk_code WHERE c.customer_id = :cid AND c.is_active = 1 """ ) with self._engine.connect() as conn: row = conn.execute(sql, {"cid": customer_id, "pid": product_id}).mappings().first() if not row: return self._suitability_result( customer_id=customer_id, product_id=product_id, match_result="forbidden", mismatch_type="not_found", matched=False, blocked=True, reason="客户或产品不存在", block_response_code="SUIT_NOT_FOUND", ) data = dict(row) base = { "customer_id": data["customer_id"], "product_id": data["product_id"], "product_name": data.get("product_name"), "customer_risk_code": data.get("customer_risk_code"), "product_risk_code": data.get("product_risk_code"), "investor_category": data.get("investor_category") or "ordinary", "age": data.get("age"), "is_hnw": data.get("is_hnw"), "risk_expires_at": data.get("risk_expires_at"), "risk_is_expired": bool(data.get("risk_is_expired")), "min_subscribe_amount": data.get("min_subscribe_amount"), "term_days": data.get("term_days"), } if base["risk_is_expired"]: return self._suitability_result( **base, match_result="risk_expired", mismatch_type="risk_expired", matched=False, blocked=True, reason="风评已过期(FM-03),须重新测评", block_response_code="SUIT_RISK_EXPIRED", ) if base["investor_category"] == "professional": return self._suitability_result( **base, match_result="professional_exempt", mismatch_type="professional_exempt", matched=True, blocked=False, requires_disclosure=False, reason="专业投资者豁免适当性匹配", block_response_code="SUIT_PROFESSIONAL_EXEMPT", ) matrix = data.get("matrix_match_result") if matrix == "forbidden" or matrix is None: return self._suitability_result( **base, match_result="forbidden", mismatch_type="risk_level", matched=False, blocked=True, reason="客户风险等级与产品最低等级不匹配", block_response_code="SUIT_RISK_MISMATCH", ) requires_disclosure = matrix == "allowed_with_disclosure" or bool( data.get("product_requires_disclosure") ) matched = True blocked = False match_result = "allowed_with_disclosure" if requires_disclosure else "allowed" block_response_code = "SUIT_NEED_DISCLOSURE" if requires_disclosure else "SUIT_OK" mismatch_type = "none" reason: str | None = None age = base.get("age") or 0 prod_r = base.get("product_risk_code") or "" needs_branch_confirm = age >= 70 and prod_r >= "R3" if needs_branch_confirm: blocked = True mismatch_type = "age_branch_confirm" reason = "年龄70岁及以上购买R3及以上产品需网点当面确认(FM-01)" block_response_code = "SUIT_AGE_CONFIRM" return self._suitability_result( **base, match_result=match_result, mismatch_type=mismatch_type, matched=matched, blocked=blocked, requires_disclosure=requires_disclosure, needs_branch_confirm=needs_branch_confirm, reason=reason, block_response_code=block_response_code, ) @staticmethod def _suitability_result( *, customer_id: str | None = None, product_id: str | None = None, product_name: str | None = None, customer_risk_code: str | None = None, product_risk_code: str | None = None, investor_category: str = "ordinary", age: int | None = None, is_hnw: bool | None = None, risk_expires_at: Any = None, risk_is_expired: bool = False, min_subscribe_amount: Any = None, term_days: Any = None, match_result: str, mismatch_type: str, matched: bool, blocked: bool, requires_disclosure: bool = False, needs_branch_confirm: bool = False, reason: str | None = None, block_response_code: str = "SUIT_OK", ) -> dict[str, Any]: return { "customer_id": customer_id, "product_id": product_id, "product_name": product_name, "customer_risk_code": customer_risk_code, "product_risk_code": product_risk_code, "investor_category": investor_category, "age": age, "is_hnw": is_hnw, "risk_expires_at": risk_expires_at, "risk_is_expired": risk_is_expired, "min_subscribe_amount": min_subscribe_amount, "term_days": term_days, "match_result": match_result, "mismatch_type": mismatch_type, "matched": matched, "blocked": blocked, "requires_disclosure": requires_disclosure, "needs_branch_confirm": needs_branch_confirm, "reason": reason, "block_response_code": block_response_code, } def list_products_for_customer( self, customer_id: str, limit: int = 50 ) -> list[dict[str, Any]]: """C-11:列出开放产品及与客户 C 等级的匹配结果。""" sql = text( """ SELECT p.*, sr.match_result, (r.expires_at < CURDATE()) AS risk_is_expired, r.risk_code AS customer_risk_code, r.investor_category FROM core_product p CROSS JOIN core_customer_risk r LEFT JOIN core_suitability_rule sr ON sr.customer_risk_code = r.risk_code AND sr.product_risk_code = p.min_risk_code WHERE r.customer_id = :cid AND p.is_open = 1 ORDER BY p.min_risk_code, p.product_id LIMIT :lim """ ) with self._engine.connect() as conn: rows = conn.execute(sql, {"cid": customer_id, "lim": limit}).mappings().all() return [dict(r) for r in rows] def list_holdings(self, customer_id: str) -> list[dict[str, Any]]: sql = text( """ SELECT h.*, p.product_name, p.min_risk_code, p.product_type, p.min_subscribe_amount, p.term_days FROM core_holding h JOIN core_product p ON p.product_id = h.product_id WHERE h.customer_id = :cid ORDER BY h.market_value DESC """ ) with self._engine.connect() as conn: return [dict(r) for r in conn.execute(sql, {"cid": customer_id}).mappings()] def list_trades( self, customer_id: str, since: date | None = None, limit: int = 50 ) -> list[dict[str, Any]]: sql = text( """ SELECT t.*, p.product_name, p.min_risk_code FROM core_trade t JOIN core_product p ON p.product_id = t.product_id WHERE t.customer_id = :cid AND (:since IS NULL OR t.traded_at >= :since) ORDER BY t.traded_at DESC LIMIT :lim """ ) with self._engine.connect() as conn: return [ dict(r) for r in conn.execute( sql, {"cid": customer_id, "since": since, "lim": limit} ).mappings() ] def get_product(self, product_id: str) -> dict[str, Any] | None: sql = text("SELECT * FROM core_product WHERE product_id = :pid") with self._engine.connect() as conn: row = conn.execute(sql, {"pid": product_id}).mappings().first() return dict(row) if row else None def get_latest_nav(self, product_id: str) -> dict[str, Any] | None: sql = text( """ SELECT * FROM core_product_nav WHERE product_id = :pid ORDER BY nav_date DESC LIMIT 1 """ ) with self._engine.connect() as conn: row = conn.execute(sql, {"pid": product_id}).mappings().first() return dict(row) if row else None def list_customers_by_advisor(self, advisor_id: str) -> list[str]: sql = text( """ SELECT customer_id FROM core_customer_advisor WHERE advisor_id = :aid AND rel_status = 'active' """ ) with self._engine.connect() as conn: return [r[0] for r in conn.execute(sql, {"aid": advisor_id})] def get_staff(self, staff_id: str) -> dict[str, Any] | None: sql = text( "SELECT staff_id, display_name, staff_type, roles FROM core_staff WHERE staff_id = :sid AND is_active = 1" ) with self._engine.connect() as conn: row = conn.execute(sql, {"sid": staff_id}).mappings().first() return dict(row) if row else None def is_advisor_assigned(self, advisor_id: str, customer_id: str) -> bool: sql = text( """ SELECT 1 FROM core_customer_advisor WHERE advisor_id = :aid AND customer_id = :cid AND rel_status = 'active' LIMIT 1 """ ) with self._engine.connect() as conn: return conn.execute(sql, {"aid": advisor_id, "cid": customer_id}).first() is not None