"""海报内容构建器 — 将计划书和小册子数据组装为单图/长图模块。""" import logging logger = logging.getLogger(__name__) def build_poster_content( parsed_data: dict, product_rules: dict, output_mode: str = "single", plan_type: str = "other", ) -> dict: """组装海报内容模块。 参数: parsed_data: 计划书解析数据(含 benefit_table) product_rules: 产品小册子解析数据(含 features 来源页) output_mode: 'single' 或 'long' 返回: 结构化的海报内容 dict,供前端 PosterHtmlCanvas 使用 """ from insurance.poster.field_profiles import get_field_profile profile = get_field_profile(plan_type, output_mode) features = _build_features(product_rules)[:profile["maxFeatureCount"]] benefit_table = _select_benefit_rows( _build_benefit_table(parsed_data), profile["milestoneYears"], profile["outputMode"], ) if profile["planType"] in ("ci", "other"): benefit_table = [] content = { "summary": _build_summary(parsed_data), "features": features, "benefit_table": benefit_table, "bonus_mechanism": product_rules.get("bonus_mechanism", ""), "flexible_options": product_rules.get("flexible_options", []), "risk_warnings": product_rules.get("risk_warnings", []), "investment_rules": product_rules.get("investment_rules", {}), "currency_options": product_rules.get("currency_options", []), "data_completeness": _assess_completeness(parsed_data, product_rules), "field_profile": profile, "warnings": _profile_warnings(profile, parsed_data, product_rules), } return content def _select_benefit_rows(rows: list, milestone_years: list[int], output_mode: str) -> list: """按画像裁剪年度数据,绝不把完整利益表塞进单图。""" if not rows or not milestone_years: return [] ordered = sorted(rows, key=lambda row: row.get("year") or 0) selected = [row for row in ordered if row.get("year") in milestone_years] if output_mode == "single": return (selected or ordered)[:1] # 命中部分里程碑时,用原表中的真实年度补足图表最低三行;不插值、不编造。 target_count = min(6, len(ordered)) if len(selected) < target_count: if target_count == 1: representative = ordered else: representative = [ ordered[round(index * (len(ordered) - 1) / (target_count - 1))] for index in range(target_count) ] selected_years = {row.get("year") for row in selected} for row in representative + ordered: if row.get("year") not in selected_years: selected.append(row) selected_years.add(row.get("year")) if len(selected) >= target_count: break return sorted(selected, key=lambda row: row.get("year") or 0)[:6] def _profile_warnings(profile: dict, parsed_data: dict, product_rules: dict) -> list[str]: warnings = [] if profile["planType"] == "ci" and profile["outputMode"] == "long": if not product_rules.get("coverage_highlights"): warnings.append("保障列表为空,已使用简版长图") if profile["outputMode"] == "long" and profile["planType"] in ("savings", "iul"): rows = _build_benefit_table(parsed_data) if len(rows) < 3: warnings.append("利益演示数据不足,已隐藏年度图表") return warnings def _build_summary(parsed_data: dict) -> dict: """构建客户摘要卡片数据。""" return { "age": parsed_data.get("age"), "gender": parsed_data.get("gender"), "smoking_status": parsed_data.get("smoking_status"), "currency": parsed_data.get("currency"), "sum_assured": parsed_data.get("sum_assured"), "basic_sum_assured": parsed_data.get("basic_sum_assured"), "annual_premium": parsed_data.get("annual_premium"), "basic_plan_annual_premium": parsed_data.get("basic_plan_annual_premium"), "total_premium": parsed_data.get("total_premium"), "first_year_amount_due": parsed_data.get("first_year_amount_due"), "premium_term": parsed_data.get("premium_term"), "coverage_period": parsed_data.get("coverage_period"), "initial_death_benefit": parsed_data.get("initial_death_benefit"), "surrender_value_10": parsed_data.get("surrender_value_10"), "surrender_value_20": parsed_data.get("surrender_value_20"), "surrender_value_30": parsed_data.get("surrender_value_30"), } def _build_features(product_rules: dict) -> list: """构建产品亮点列表,保留来源页码。 优先使用小册子提取的 features(带 source_page), 如果小册子无数据,回退到计划书的 key_benefits。 """ features = product_rules.get("features", []) if features: result = [] for f in features[:8]: item = { "title": f.get("title", ""), "summary": f.get("summary", ""), "source_page": f.get("source_page"), } if item["title"]: result.append(item) return result # 回退:使用计划书的 key_benefits return [] # 由调用方从 parsed_data.key_benefits 补充 def _build_benefit_table(parsed_data: dict) -> list: """构建利益演示表数据(供图表组件使用)。""" table = ( parsed_data.get("benefit_table") or parsed_data.get("benefit_illustration") or [] ) result = [] for row in table: entry = { "year": row.get("year") or row.get("policy_year") or row.get("policyYear") or 0, "guaranteed": row.get("guaranteed_cash_value") or row.get("guaranteed_csv") or row.get("guaranteedCashValue") or row.get("guaranteed") or 0, "nonGuaranteed": row.get("non_guaranteed_cash_value") or row.get("non_guaranteed") or row.get("nonGuaranteed") or 0, "totalSurrender": row.get("total_surrender_value") or row.get("total_surrender") or row.get("totalSurrenderValue") or row.get("total") or 0, "deathBenefit": row.get("death_benefit") or row.get("deathBenefit") or 0, } if entry["year"] > 0: result.append(entry) by_year = {int(row["year"]): row for row in result} for year in (10, 20, 30): explicit_value = parsed_data.get(f"surrender_value_{year}") if explicit_value is None: continue if year not in by_year: by_year[year] = { "year": year, "guaranteed": 0, "nonGuaranteed": 0, "totalSurrender": explicit_value, "deathBenefit": 0, } result.append(by_year[year]) else: by_year[year]["totalSurrender"] = explicit_value return result def _assess_completeness(parsed_data: dict, product_rules: dict) -> dict: """评估数据完整度,决定哪些模块应显示。 返回: {field: bool} 表示各模块是否有足够数据 """ has_benefit_table = bool(parsed_data.get("benefit_table") or parsed_data.get("benefit_illustration")) benefit_rows = parsed_data.get("benefit_table") or parsed_data.get("benefit_illustration") or [] return { "has_summary": bool(parsed_data.get("age") or parsed_data.get("sum_assured")), "has_benefit_chart": has_benefit_table and len(benefit_rows) >= 3, "has_features": bool(product_rules.get("features")), "has_currency_options": bool(product_rules.get("currency_options")), "has_bonus": bool(product_rules.get("bonus_mechanism")), "has_flexible_options": bool(product_rules.get("flexible_options")), "has_risk_warnings": bool(product_rules.get("risk_warnings")), }