2026-09-05 17:39:16 +08:00
|
|
|
"""Core 模拟库只读访问(jinrong_core · 无 HTTP API)。"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-09-07 11:15:30 +08:00
|
|
|
from datetime import date
|
|
|
|
|
from typing import Any, Literal
|
2026-09-05 17:39:16 +08:00
|
|
|
|
|
|
|
|
from sqlalchemy import create_engine, text
|
|
|
|
|
from sqlalchemy.engine import Engine
|
|
|
|
|
|
|
|
|
|
from app.config.settings import settings
|
|
|
|
|
|
2026-09-07 11:15:30 +08:00
|
|
|
MatchResult = Literal["allowed", "allowed_with_disclosure", "forbidden", "professional_exempt", "risk_expired"]
|
|
|
|
|
|
2026-09-05 17:39:16 +08:00
|
|
|
|
|
|
|
|
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:
|
2026-09-07 11:15:30 +08:00
|
|
|
"""L0 客户主档 + 正式风评(对齐用户信息数据示例 / 适当性指南)。"""
|
2026-09-05 17:39:16 +08:00
|
|
|
sql = text(
|
|
|
|
|
"""
|
2026-09-07 11:15:30 +08:00
|
|
|
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
|
2026-09-05 17:39:16 +08:00
|
|
|
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
|
|
|
|
|
|
2026-09-07 11:15:30 +08:00
|
|
|
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]
|
|
|
|
|
|
2026-09-05 17:39:16 +08:00
|
|
|
def list_holdings(self, customer_id: str) -> list[dict[str, Any]]:
|
|
|
|
|
sql = text(
|
|
|
|
|
"""
|
2026-09-07 11:15:30 +08:00
|
|
|
SELECT h.*, p.product_name, p.min_risk_code, p.product_type,
|
|
|
|
|
p.min_subscribe_amount, p.term_days
|
2026-09-05 17:39:16 +08:00
|
|
|
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(
|
|
|
|
|
"""
|
2026-09-07 11:15:30 +08:00
|
|
|
SELECT t.*, p.product_name, p.min_risk_code
|
2026-09-05 17:39:16 +08:00
|
|
|
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
|