AL-03: core_ro.py 融合对齐 main 基准——我方骨架保留(utils/db.get_engine 统一引擎工厂,main 自带 create_engine 工厂不采纳;风控扩展 get_trade_by_id/sum_trades_on_date/list_trades_range/list_active_customers 全保留),吸收 main 四项+1:①get_customer_l0 扩列版(c.*+风评新列+risk_is_expired) ②check_suitability+_suitability_result(C×R 矩阵判定,判定链 not_found→FM-03 过期→JR-AST-PRO 豁免→JR-AST-012 矩阵→披露→FM-01 网点确认) ③list_products_for_customer(C-11) ④list_holdings 合并(limit=500 截断防护+min_subscribe_amount/term_days 新列) ⑤list_trades(差距#13 新查询能力)。移植坑消解:main 三处 CURDATE() SQL 表达式统一改 Python 端 _is_expired 计算(sqlite 测试库兼容),date/datetime/str 统一 _as_date 归一。双方言验收:MySQL 真库(not_found/expired 真跑)+sqlite 七路径可控日期验证通过;pytest 失败面与 AL-02 基线一致(15 failed 均旧契约断言)
This commit is contained in:
+271
-6
@@ -1,10 +1,22 @@
|
||||
"""Core 模拟库只读访问(jinrong_core · 无 HTTP API)。"""
|
||||
"""Core 模拟库只读访问(jinrong_core · 无 HTTP API)。
|
||||
|
||||
AL-03 对齐 main 基准(2026-09-07):
|
||||
- 骨架为我方版本(utils/db.get_engine 统一引擎工厂——main 自带 create_engine 工厂不采纳,
|
||||
避免破坏统一引擎管理;get_trade_by_id / sum_trades_on_date / list_trades_range /
|
||||
list_active_customers 等风控扩展方法保留)。
|
||||
- 自 main 吸收:get_customer_l0 扩列版(c.* + 风评新列 + risk_is_expired)、
|
||||
check_suitability + _suitability_result(R-02 矩阵判定,C×R 数据驱动)、
|
||||
list_products_for_customer(C-11)、list_trades、list_holdings 增
|
||||
min_subscribe_amount/term_days 列(limit=500 截断防护为我方 T-04 评审保留)。
|
||||
- 移植坑消解:main SQL 中 `(r.expires_at < CURDATE())` 为 MySQL 专属函数,
|
||||
sqlite 测试库会炸——统一改为取回 expires_at 后 Python 端计算(_is_expired)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
@@ -12,6 +24,29 @@ from sqlalchemy.engine import Engine
|
||||
from app.config.settings import settings
|
||||
from app.utils.db import get_engine
|
||||
|
||||
MatchResult = Literal[
|
||||
"allowed", "allowed_with_disclosure", "forbidden", "professional_exempt", "risk_expired"
|
||||
]
|
||||
|
||||
|
||||
def _as_date(value: Any) -> date | None:
|
||||
"""DB 取回的日期值统一转 date(MySQL DATE→date / sqlite TIMESTAMP→datetime / str 兜底)。"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
|
||||
|
||||
def _is_expired(value: Any, today: date | None = None) -> bool:
|
||||
"""FM-03 风评过期判定(Python 端,替代 main 的 CURDATE() SQL 表达式)。"""
|
||||
d = _as_date(value)
|
||||
if d is None:
|
||||
return False
|
||||
return d < (today or date.today())
|
||||
|
||||
|
||||
class CoreReadOnlyRepository:
|
||||
"""仅 SELECT jinrong_core;禁止写操作。"""
|
||||
@@ -20,10 +55,14 @@ class CoreReadOnlyRepository:
|
||||
self._engine = engine or get_engine(settings.mysql_core_database)
|
||||
|
||||
def get_customer_l0(self, customer_id: str) -> dict[str, Any] | None:
|
||||
"""L0 客户主档 + 正式风评(对齐用户信息数据示例 / 适当性指南;main 扩列版)。"""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT c.customer_id, c.display_name, c.age, c.occupation, c.open_date,
|
||||
r.risk_code, r.evaluated_at AS risk_evaluated_at
|
||||
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
|
||||
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
|
||||
@@ -31,17 +70,220 @@ class CoreReadOnlyRepository:
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sql, {"cid": customer_id}).mappings().first()
|
||||
return dict(row) if row else None
|
||||
if not row:
|
||||
return None
|
||||
data = dict(row)
|
||||
# FM-03 过期判定 Python 端算(替代 main 的 CURDATE(),sqlite 兼容)
|
||||
data["risk_is_expired"] = _is_expired(data.get("risk_expires_at"))
|
||||
return data
|
||||
|
||||
def check_suitability(self, customer_id: str, product_id: str) -> dict[str, Any]:
|
||||
"""R-02:基于 L0 C 等级 + 产品 R 等级 + 适当性矩阵判定(main 契约)。
|
||||
|
||||
判定顺序:not_found → risk_expired(FM-03) → professional_exempt(JR-AST-PRO)
|
||||
→ 矩阵 forbidden(JR-AST-012) → 披露 → 年龄≥70 买 R3+ 网点确认(FM-01)。
|
||||
返回字段与 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,
|
||||
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": _is_expired(data.get("risk_expires_at")),
|
||||
"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 等级的匹配结果(main 契约)。"""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT p.*, sr.match_result,
|
||||
r.expires_at AS risk_expires_at,
|
||||
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()
|
||||
result = [dict(r) for r in rows]
|
||||
# FM-03 过期判定 Python 端算(替代 main 的 CURDATE(),sqlite 兼容)
|
||||
for item in result:
|
||||
item["risk_is_expired"] = _is_expired(item.pop("risk_expires_at", None))
|
||||
return result
|
||||
|
||||
def list_holdings(self, customer_id: str, limit: int = 500) -> list[dict[str, Any]]:
|
||||
"""持仓明细(市值降序);limit 为 SQL 层保护上限(T-04 评审 P2)。
|
||||
|
||||
注意:调用方若要精确合计,须自行判断 len(rows) 是否触及 limit
|
||||
(core_tools.query_holdings 以 truncated 字段对外暴露)。
|
||||
main 扩列:min_subscribe_amount / term_days(C-11 起购与期限口径)。
|
||||
"""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT h.*, p.product_name, p.min_risk_code, p.product_type
|
||||
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
|
||||
@@ -55,6 +297,29 @@ class CoreReadOnlyRepository:
|
||||
for r in conn.execute(sql, {"cid": customer_id, "lim": limit}).mappings()
|
||||
]
|
||||
|
||||
def list_trades(
|
||||
self, customer_id: str, since: date | None = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
"""客户流水(时间降序 + 产品名 JOIN;main 新增查询能力)。"""
|
||||
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 list_trades_range(
|
||||
self, customer_id: str, start: datetime, end: datetime, limit: int = 10000
|
||||
) -> list[dict[str, Any]]:
|
||||
|
||||
Reference in New Issue
Block a user