1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
195 lines
7.9 KiB
Python
195 lines
7.9 KiB
Python
"""场外基金申购赎回确定性规则。"""
|
||
|
||
import re
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime
|
||
from decimal import Decimal, InvalidOperation
|
||
|
||
from app.core.offsite_fund_contracts import RuleResultStatus
|
||
|
||
HUNDRED = Decimal("100")
|
||
TEN_PERCENT = Decimal("0.10")
|
||
TWENTY_PERCENT = Decimal("0.20")
|
||
ONE_YUAN = Decimal("1")
|
||
|
||
|
||
def display_decimal(value: Decimal) -> str:
|
||
"""把计算结果格式化成适合页面展示的十进制文本。"""
|
||
text = format(value, "f")
|
||
if "." in text:
|
||
text = text.rstrip("0").rstrip(".")
|
||
return text or "0"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RuleDecision:
|
||
rule_code: str
|
||
rule_name: str
|
||
result: RuleResultStatus
|
||
document_value: dict[str, object]
|
||
database_value: dict[str, object]
|
||
calculation: dict[str, object]
|
||
|
||
|
||
def decimal_from(value: object) -> Decimal | None:
|
||
if value is None or value == "":
|
||
return None
|
||
try:
|
||
return Decimal(str(value).replace(",", "").strip())
|
||
except (InvalidOperation, ValueError):
|
||
return None
|
||
|
||
|
||
def normalize_amount_yuan(raw_value: object, unit: object) -> Decimal | None:
|
||
raw_unit = str(unit or "元").strip().replace("人民币", "元")
|
||
raw_text = str(raw_value or "").replace(",", "").replace(",", "").strip()
|
||
if raw_unit and raw_text.endswith(raw_unit):
|
||
raw_text = raw_text[: -len(raw_unit)].strip()
|
||
# OCR 可能把币种前缀一起识别到金额字段,例如“人民币5,000.00元”。
|
||
# 只接受开头的常见币种标记,避免从任意业务文本中误抽数字。
|
||
raw_text = re.sub(r"^(?:人民币|RMB|CNY|¥|¥)\s*", "", raw_text, flags=re.IGNORECASE)
|
||
amount = decimal_from(raw_text)
|
||
if amount is None:
|
||
return None
|
||
if raw_unit == "万元":
|
||
return amount * Decimal("10000")
|
||
if raw_unit in {"元", "人民币", "CNY"}:
|
||
return amount
|
||
return None
|
||
|
||
|
||
def parse_application_date(raw_value: object) -> date | None:
|
||
text = str(raw_value or "").strip()
|
||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y年%m月%d日", "%Y%m%d"):
|
||
try:
|
||
return datetime.strptime(text, fmt).date()
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
class OffsiteFundRuleEngine:
|
||
"""只做程序化计算,不读取数据库,也不调用模型。"""
|
||
|
||
def check_subscription(
|
||
self,
|
||
*,
|
||
amount_yuan: Decimal | None,
|
||
nav: Decimal | None,
|
||
total_fund_shares: Decimal | None,
|
||
before_holding_shares: Decimal | None,
|
||
) -> list[RuleDecision]:
|
||
decisions = [self._minimum_subscription(amount_yuan)]
|
||
if (amount_yuan is None or nav is None or nav <= 0
|
||
or total_fund_shares is None or total_fund_shares == 0):
|
||
return decisions + [
|
||
self._unknown("subscription_holding_ratio", "申购后单一投资者持有比例"),
|
||
self._unknown("subscription_single_share_limit", "申购单笔份额上限"),
|
||
]
|
||
current_shares = amount_yuan / nav
|
||
before = before_holding_shares or Decimal("0")
|
||
ratio = (before + current_shares) / total_fund_shares
|
||
decisions.append(RuleDecision(
|
||
rule_code="subscription_holding_ratio",
|
||
rule_name="申购后单一投资者持有比例",
|
||
result="异常" if ratio > TWENTY_PERCENT else "正常",
|
||
document_value={"申购金额元": str(amount_yuan)},
|
||
database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares),
|
||
"申请前持有份额": str(before)},
|
||
calculation={
|
||
"本次申购份额": str(current_shares),
|
||
"申购后持有比例": str(ratio),
|
||
"实际值": f"{display_decimal(ratio * HUNDRED)}%",
|
||
"规则值": "≤ 20%",
|
||
"比较": f"{display_decimal(ratio * HUNDRED)}% ≤ 20%",
|
||
},
|
||
))
|
||
limit = total_fund_shares * TEN_PERCENT
|
||
decisions.append(RuleDecision(
|
||
rule_code="subscription_single_share_limit",
|
||
rule_name="申购单笔份额上限",
|
||
result="异常" if current_shares > limit else "正常",
|
||
document_value={"申购金额元": str(amount_yuan)},
|
||
database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares)},
|
||
calculation={
|
||
"本次申购份额": str(current_shares),
|
||
"份额上限": str(limit),
|
||
"实际值": str(current_shares),
|
||
"规则值": str(limit),
|
||
"比较": f"{current_shares} ≤ {limit}",
|
||
},
|
||
))
|
||
return decisions
|
||
|
||
def check_redemption(
|
||
self,
|
||
*,
|
||
redemption_shares: Decimal | None,
|
||
total_fund_shares: Decimal | None,
|
||
available_quantity: Decimal | None,
|
||
) -> list[RuleDecision]:
|
||
if redemption_shares is None or total_fund_shares is None or total_fund_shares == 0:
|
||
ratio = self._unknown("redemption_large_ratio", "赎回巨额比例")
|
||
else:
|
||
value = redemption_shares / total_fund_shares
|
||
ratio = RuleDecision(
|
||
rule_code="redemption_large_ratio",
|
||
rule_name="赎回巨额比例",
|
||
result="异常" if value > TWENTY_PERCENT else "正常",
|
||
document_value={"赎回份额": str(redemption_shares)},
|
||
database_value={"产品最新总份额": str(total_fund_shares)},
|
||
calculation={
|
||
"赎回比例": str(value),
|
||
"实际值": f"{display_decimal(value * HUNDRED)}%",
|
||
"规则值": "≤ 20%",
|
||
"比较": f"{display_decimal(value * HUNDRED)}% ≤ 20%",
|
||
},
|
||
)
|
||
if redemption_shares is None or available_quantity is None:
|
||
available = self._unknown("redemption_available_quantity", "账户可用份额")
|
||
else:
|
||
available = RuleDecision(
|
||
rule_code="redemption_available_quantity",
|
||
rule_name="账户可用份额",
|
||
result="异常" if redemption_shares > available_quantity else "正常",
|
||
document_value={"赎回份额": str(redemption_shares)},
|
||
database_value={"当前最新可用份额": str(available_quantity)},
|
||
calculation={
|
||
"是否超出可用份额": redemption_shares > available_quantity,
|
||
"实际值": str(redemption_shares),
|
||
"规则值": str(available_quantity),
|
||
"比较": f"{redemption_shares} ≤ {available_quantity}",
|
||
},
|
||
)
|
||
return [ratio, available]
|
||
|
||
@staticmethod
|
||
def _minimum_subscription(amount_yuan: Decimal | None) -> RuleDecision:
|
||
result: RuleResultStatus = "无法判断"
|
||
if amount_yuan is not None:
|
||
result = "异常" if amount_yuan <= ONE_YUAN else "正常"
|
||
return RuleDecision(
|
||
rule_code="subscription_minimum_amount",
|
||
rule_name="申购最低金额",
|
||
result=result,
|
||
document_value={"申购金额元": str(amount_yuan) if amount_yuan is not None else None},
|
||
database_value={},
|
||
calculation={
|
||
"判断口径": "标准化申购金额 <= 1 元为异常",
|
||
"实际值": str(amount_yuan) if amount_yuan is not None else None,
|
||
"规则值": "> 1 元",
|
||
"比较": f"{amount_yuan} > 1 元" if amount_yuan is not None else None,
|
||
},
|
||
)
|
||
|
||
@staticmethod
|
||
def _unknown(rule_code: str, rule_name: str) -> RuleDecision:
|
||
return RuleDecision(
|
||
rule_code=rule_code,
|
||
rule_name=rule_name,
|
||
result="无法判断",
|
||
document_value={},
|
||
database_value={},
|
||
calculation={"原因": "必要识别字段或查询结果缺失"},
|
||
)
|