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:
2026-09-07 17:11:52 +08:00
parent e99ec6775a
commit ba3465a953
+271 -6
View File
@@ -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]]: