08-4 修复第二版
This commit is contained in:
parent
6d0d668323
commit
9ef2b24098
@ -60,6 +60,8 @@ POSTER_USER_MANUAL_UPLOAD_ENABLED=true
|
||||
POSTGRES_DB=baodan
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=your_password_here
|
||||
SQLALCHEMY_POOL_PRE_PING=true
|
||||
SQLALCHEMY_POOL_RECYCLE=300
|
||||
|
||||
# 应用配置
|
||||
SECRET_KEY=your_secret_key_here
|
||||
|
||||
@ -1675,38 +1675,59 @@ POSTER_CASE_PARSE_HARD_TIMEOUT = 360
|
||||
bind=True,
|
||||
name="insurance.parse_poster_case",
|
||||
queue="insurance",
|
||||
max_retries=5,
|
||||
default_retry_delay=5,
|
||||
soft_time_limit=POSTER_CASE_PARSE_SOFT_TIMEOUT,
|
||||
time_limit=POSTER_CASE_PARSE_HARD_TIMEOUT,
|
||||
)
|
||||
def parse_poster_case_task(self, case_upload_id: int):
|
||||
"""在 Worker 中解析海报计划书,数据库状态转换保证幂等。"""
|
||||
from insurance.db.compat import db
|
||||
|
||||
claimed = db.session.execute(
|
||||
db.text(
|
||||
"UPDATE poster_case_uploads SET parse_status = 'parsing', "
|
||||
"parse_progress = 10, parse_message = '正在读取 PDF 文本', "
|
||||
"parse_error = NULL, parse_started_at = NOW(), "
|
||||
"parse_heartbeat_at = NOW(), parse_finished_at = NULL "
|
||||
"WHERE id = :id AND parse_status = 'queued'"
|
||||
),
|
||||
{"id": case_upload_id},
|
||||
)
|
||||
db.session.commit()
|
||||
if claimed.rowcount == 0:
|
||||
logger.info("海报计划书 %s 已被领取或不在 queued 状态,跳过", case_upload_id)
|
||||
return
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
try:
|
||||
try:
|
||||
claimed = db.session.execute(
|
||||
db.text(
|
||||
"UPDATE poster_case_uploads SET parse_status = 'parsing', "
|
||||
"parse_progress = 10, parse_message = '正在读取 PDF 文本', "
|
||||
"parse_error = NULL, parse_started_at = NOW(), "
|
||||
"parse_heartbeat_at = NOW(), parse_finished_at = NULL "
|
||||
"WHERE id = :id AND parse_status = 'queued'"
|
||||
),
|
||||
{"id": case_upload_id},
|
||||
)
|
||||
db.session.commit()
|
||||
except OperationalError as exc:
|
||||
db.session.rollback()
|
||||
retries = int(getattr(getattr(self, "request", None), "retries", 0) or 0)
|
||||
if retries >= int(getattr(self, "max_retries", 5) or 5):
|
||||
logger.error("海报计划书 %s 数据库断线重试已耗尽", case_upload_id, exc_info=True)
|
||||
raise
|
||||
countdown = min(5 * (2 ** retries), 60)
|
||||
logger.warning(
|
||||
"海报计划书 %s 领取时数据库连接中断,%s 秒后重试(%s/5)",
|
||||
case_upload_id,
|
||||
countdown,
|
||||
retries + 1,
|
||||
)
|
||||
self.retry(exc=exc, countdown=countdown)
|
||||
return
|
||||
|
||||
if claimed.rowcount == 0:
|
||||
logger.info("海报计划书 %s 已被领取或不在 queued 状态,跳过", case_upload_id)
|
||||
return
|
||||
|
||||
from insurance.poster.tasks import _execute_case_parse
|
||||
|
||||
_execute_case_parse(case_upload_id)
|
||||
except Exception as exc:
|
||||
logger.error("海报计划书解析失败 [%s]: %s", case_upload_id, exc, exc_info=True)
|
||||
from insurance.poster.tasks import _mark_case_failed
|
||||
try:
|
||||
_execute_case_parse(case_upload_id)
|
||||
except Exception as exc:
|
||||
logger.error("海报计划书解析失败 [%s]: %s", case_upload_id, exc, exc_info=True)
|
||||
from insurance.poster.tasks import _mark_case_failed
|
||||
|
||||
_mark_case_failed(case_upload_id, str(exc))
|
||||
raise
|
||||
_mark_case_failed(case_upload_id, str(exc))
|
||||
raise
|
||||
finally:
|
||||
db.session.remove()
|
||||
|
||||
|
||||
@ -26,12 +26,21 @@ def reconcile_pptx(deck_contract: dict | None, pptx_path: str) -> dict:
|
||||
manifest = deck_contract.get("reconciliationManifest")
|
||||
expected_source = manifest if isinstance(manifest, list) else deck_contract
|
||||
expected_text = "\n".join(_scalar_values(expected_source))
|
||||
# 必显清单只决定哪些字段必须出现在成品中;允许值仍来自完整冻结契约。
|
||||
# 否则清单未列出的合法原始金额、确定性派生金额都会被误判为新增金额。
|
||||
allowed_text = "\n".join(_scalar_values(deck_contract))
|
||||
expected_numbers = sorted(set(_normalized_numbers(expected_text)))
|
||||
actual_numbers = set(_normalized_numbers(actual_text))
|
||||
allowed_numbers = set(expected_numbers) | {str(value) for value in range(0, 201)}
|
||||
allowed_numbers = set(_normalized_numbers(allowed_text)) | {str(value) for value in range(0, 201)}
|
||||
unexpected_numbers = sorted(actual_numbers - allowed_numbers)
|
||||
missing_numbers = [value for value in expected_numbers if value not in actual_numbers]
|
||||
expected_currencies = sorted(set(_currency_code(item) for item in CURRENCY_PATTERN.findall(expected_text)))
|
||||
currency_source = manifest if isinstance(manifest, list) else [
|
||||
(item.get("policy") or {}).get("currency")
|
||||
for item in deck_contract.get("products") or []
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
currency_text = "\n".join(_scalar_values(currency_source))
|
||||
expected_currencies = sorted(set(_currency_code(item) for item in CURRENCY_PATTERN.findall(currency_text)))
|
||||
actual_currencies = set(_currency_code(item) for item in CURRENCY_PATTERN.findall(actual_text))
|
||||
missing_currencies = [value for value in expected_currencies if value not in actual_currencies]
|
||||
missing_entries = []
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, jsonify, request
|
||||
from insurance.middleware.auth_middleware import account_required
|
||||
from insurance.utils.response import success, error, ErrorCode
|
||||
|
||||
@ -96,7 +96,14 @@ def save_ppt_draft(session_id):
|
||||
# 乐观锁检查
|
||||
expected = data.get("expected_revision")
|
||||
if expected is not None and expected != session.draft_revision:
|
||||
return error(ErrorCode.PARAM_ERROR, f"草稿版本冲突:当前 v{session.draft_revision},请求 v{expected}")
|
||||
return jsonify({
|
||||
"code": ErrorCode.PARAM_ERROR,
|
||||
"message": f"草稿版本冲突:当前 v{session.draft_revision},请求 v{expected}",
|
||||
"data": {
|
||||
"current_revision": session.draft_revision,
|
||||
"requested_revision": expected,
|
||||
},
|
||||
}), 409
|
||||
|
||||
if "draft_options" in data:
|
||||
session.draft_options_json = json.dumps(data["draft_options"], ensure_ascii=False)
|
||||
|
||||
@ -12,16 +12,16 @@ IDENTITY_FIELDS = {
|
||||
"productType": ("product_type", "plan_type"),
|
||||
}
|
||||
INSURED_FIELDS = {
|
||||
"insuredName": ("name",),
|
||||
"insuredName": ("name", "insured_name"),
|
||||
"insuredAge": ("age",),
|
||||
"insuredGender": ("gender",),
|
||||
"insuredSmoker": ("smoker",),
|
||||
"insuredSmoker": ("smoker", "smoking_status"),
|
||||
}
|
||||
POLICY_FIELDS = {
|
||||
"currency": ("currency",),
|
||||
"sumAssured": ("sum_insured", "sumAssured"),
|
||||
"sumAssured": ("sum_insured", "sum_assured", "sumAssured"),
|
||||
"annualPremium": ("annual_premium", "annualPremium"),
|
||||
"premiumPaymentPeriod": ("premium_payment_period", "payYears"),
|
||||
"premiumPaymentPeriod": ("premium_payment_period", "premium_term", "payYears"),
|
||||
"coveragePeriod": ("coverage_period", "coveragePeriod"),
|
||||
"contractualTotalPremium": (
|
||||
"total_premium", "contractual_total_premium", "contractualTotalPremium"
|
||||
@ -36,14 +36,15 @@ def legacy_to_plan_data(data: dict, evidence_entries: list[dict] | None = None)
|
||||
for target, aliases in IDENTITY_FIELDS.items()
|
||||
}
|
||||
insured = data.get("insured") or {}
|
||||
identity.update({
|
||||
target: _field_value(
|
||||
_first(insured, *aliases),
|
||||
for target, aliases in INSURED_FIELDS.items():
|
||||
value = _first(insured, *aliases)
|
||||
if value is None:
|
||||
value = _first(data, *aliases)
|
||||
identity[target] = _field_value(
|
||||
value,
|
||||
evidence_by_path,
|
||||
tuple(f"insured.{item}" for item in aliases),
|
||||
tuple(f"insured.{item}" for item in aliases) + aliases,
|
||||
)
|
||||
for target, aliases in INSURED_FIELDS.items()
|
||||
})
|
||||
|
||||
policy = data.get("policy") or {}
|
||||
currency = _first(policy, "currency") or data.get("currency")
|
||||
|
||||
@ -9,6 +9,13 @@ AMOUNT_TOKENS = (
|
||||
"deathbenefit", "accountvalue", "amount", "withdrawal",
|
||||
)
|
||||
|
||||
# 只有核心保单金额需要在确认时提示证据缺失。利益演示、退保价值和
|
||||
# 提领方案属于可选展示数据,不参与确认门禁。
|
||||
CRITICAL_AMOUNT_PATHS = {
|
||||
"policy.annualPremium",
|
||||
"policy.sumAssured",
|
||||
}
|
||||
|
||||
|
||||
def validate_plan_data(plan_data: dict, *, for_confirmation: bool = False) -> dict:
|
||||
issues: list[dict] = []
|
||||
@ -40,6 +47,7 @@ def validate_plan_data(plan_data: dict, *, for_confirmation: bool = False) -> di
|
||||
"CRITICAL_EVIDENCE_MISSING",
|
||||
"导出金额缺少 PDF 证据或人工覆盖原因",
|
||||
f"planData.{path}",
|
||||
severity="warning",
|
||||
))
|
||||
return _result(issues)
|
||||
|
||||
@ -74,6 +82,8 @@ def _required_confirmation_fields(plan_data: dict) -> list[dict]:
|
||||
def _is_exported_amount(path: str, field: dict) -> bool:
|
||||
if field.get("status") in {"missing", "derived"} or field.get("value") is None:
|
||||
return False
|
||||
if path not in CRITICAL_AMOUNT_PATHS:
|
||||
return False
|
||||
token = path.replace("_", "").lower()
|
||||
if any(non_amount in token for non_amount in ("period", "year", "age", "label", "source")):
|
||||
return False
|
||||
|
||||
@ -138,13 +138,6 @@ def validate_confirmed_case(record, product_snapshot: dict | None = None) -> Gat
|
||||
|
||||
def _validate_business_fields(plan_data: dict, product_snapshot: dict, override_reason: str) -> list[GateIssue]:
|
||||
issues: list[GateIssue] = []
|
||||
plan_type = str(
|
||||
plan_data.get("plan_type")
|
||||
or (plan_data.get("meta") or {}).get("planType")
|
||||
or product_snapshot.get("planType")
|
||||
or "other"
|
||||
).lower()
|
||||
|
||||
meta = plan_data.get("meta") or {}
|
||||
required = set(meta.get("requiredFields") or meta.get("required_fields") or [])
|
||||
product_profile = (
|
||||
@ -208,7 +201,8 @@ def _validate_business_fields(plan_data: dict, product_snapshot: dict, override_
|
||||
issues.extend(_validate_benefit_table(plan_data))
|
||||
issues.extend(_validate_product_match(plan_data, product_snapshot, override_reason))
|
||||
issues.extend(_validate_manual_overrides(plan_data, override_reason))
|
||||
issues.extend(_validate_critical_evidence(plan_data, plan_type, override_reason))
|
||||
# 证据完整性由 PlanData 快照校验记录为 warning;海报确认只硬性校验
|
||||
# 关键字段是否存在且值合法,利益、退保价值和提领方案不作为确认条件。
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
@ -268,9 +268,21 @@ class PosterService:
|
||||
)
|
||||
snapshot = confirm_snapshot(draft.id, user_id)
|
||||
except SnapshotError as exc:
|
||||
blocking_issue = next(
|
||||
(
|
||||
issue for issue in exc.data.get("issues", [])
|
||||
if isinstance(issue, dict) and issue.get("severity") == "error"
|
||||
),
|
||||
None,
|
||||
)
|
||||
message = exc.message
|
||||
if blocking_issue:
|
||||
issue_message = str(blocking_issue.get("message") or exc.message)
|
||||
issue_path = str(blocking_issue.get("path") or "")
|
||||
message = f"{issue_message}({issue_path})" if issue_path else issue_message
|
||||
return {
|
||||
"code": 4201,
|
||||
"message": exc.message,
|
||||
"message": message,
|
||||
"data": {"errorCode": exc.code, **exc.data},
|
||||
}
|
||||
|
||||
@ -299,11 +311,18 @@ class PosterService:
|
||||
return {"code": 1002, "message": "记录不存在", "data": None}
|
||||
if not record.source_file_url or not os.path.exists(record.source_file_url):
|
||||
return {"code": 1002, "message": "源计划书不存在,请重新上传", "data": None}
|
||||
if record.parse_status in ("queued", "parsing"):
|
||||
if record.parse_status == "parsing":
|
||||
return {"code": 0, "message": "计划书正在解析中", "data": record.to_dict()}
|
||||
|
||||
from insurance.poster.tasks import start_case_parse_task
|
||||
|
||||
# queued 但没有活跃 Worker 的记录允许安全补发。真正的并发任务仍由
|
||||
# Worker 中 queued -> parsing 的条件 UPDATE 原子去重。
|
||||
if record.parse_status == "queued":
|
||||
if not start_case_parse_task(record.id):
|
||||
return {"code": 5001, "message": "解析任务重新入队失败,请重试", "data": None}
|
||||
return {"code": 0, "message": "解析任务已重新入队", "data": record.to_dict()}
|
||||
|
||||
record.parse_status = "queued"
|
||||
record.parse_progress = 5
|
||||
record.parse_message = "任务已提交,等待解析..."
|
||||
|
||||
@ -139,6 +139,125 @@ def _build_deck_contract(
|
||||
resolved_scenario = scenario or detect_generation_scenario(products)
|
||||
resolved_mode = generation_mode or generation_mode_for_scenario(resolved_scenario)
|
||||
|
||||
def _number_or_none(value):
|
||||
if value is None or isinstance(value, bool) or (isinstance(value, str) and not value.strip()):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number == number and number not in (float("inf"), float("-inf")) else None
|
||||
|
||||
# 渲染器允许展示的确定性计算值必须先进入冻结契约,供最终输出对账使用。
|
||||
allowed_derived_values = []
|
||||
totals = []
|
||||
currencies = set()
|
||||
for product_index, product in enumerate(products):
|
||||
policy = product.get("policy") or {}
|
||||
currency = str(policy.get("currency") or "").upper()
|
||||
if currency:
|
||||
currencies.add(currency)
|
||||
total = _number_or_none(policy.get("contractualTotalPremium"))
|
||||
if total is None:
|
||||
total = _number_or_none(policy.get("totalPremium"))
|
||||
totals.append(total)
|
||||
for row in product.get("benefitRows") or []:
|
||||
bonus = _number_or_none(row.get("reversionaryBonus"))
|
||||
dividend = _number_or_none(row.get("terminalDividend"))
|
||||
if bonus is not None and dividend is not None:
|
||||
allowed_derived_values.append({
|
||||
"code": "nonGuaranteedBenefit",
|
||||
"productIndex": product_index,
|
||||
"policyYear": row.get("policyYear"),
|
||||
"value": bonus + dividend,
|
||||
})
|
||||
for row in product.get("withdrawalRows") or []:
|
||||
cumulative = _number_or_none(row.get("cumulativeWithdrawal"))
|
||||
surrender = _number_or_none(row.get("surrenderValueAfter"))
|
||||
if cumulative is not None and surrender is not None:
|
||||
allowed_derived_values.append({
|
||||
"code": "withdrawalEconomicTotal",
|
||||
"productIndex": product_index,
|
||||
"policyYear": row.get("policyYear"),
|
||||
"value": cumulative + surrender,
|
||||
})
|
||||
|
||||
benefit_by_year = {}
|
||||
for row in product.get("benefitRows") or []:
|
||||
year_value = _number_or_none(row.get("policyYear"))
|
||||
if year_value is not None and year_value > 0:
|
||||
benefit_by_year[int(year_value)] = row
|
||||
withdrawal_by_year = {}
|
||||
for row in product.get("withdrawalRows") or []:
|
||||
year_value = _number_or_none(row.get("policyYear"))
|
||||
if year_value is not None and year_value > 0:
|
||||
withdrawal_by_year[int(year_value)] = row
|
||||
for year in [1, 10, 20, 30, 40, 50, 60, 70, 80, 90]:
|
||||
base_row = benefit_by_year.get(year)
|
||||
withdrawal_row = withdrawal_by_year.get(year)
|
||||
if not base_row and not withdrawal_row:
|
||||
continue
|
||||
paid = _number_or_none((base_row or {}).get("totalPremiumPaid"))
|
||||
if withdrawal_row:
|
||||
cumulative = _number_or_none(withdrawal_row.get("cumulativeWithdrawal"))
|
||||
surrender = _number_or_none(withdrawal_row.get("surrenderValueAfter"))
|
||||
economic_total = cumulative + surrender if cumulative is not None and surrender is not None else None
|
||||
else:
|
||||
economic_total = _number_or_none((base_row or {}).get("totalSurrenderValue"))
|
||||
if paid is None or paid <= 0 or economic_total is None:
|
||||
continue
|
||||
simple_rate = (economic_total / paid - 1.0) * 100.0
|
||||
allowed_derived_values.append({
|
||||
"code": "simpleReturn",
|
||||
"productIndex": product_index,
|
||||
"policyYear": year,
|
||||
"value": f"{simple_rate:.2f}%",
|
||||
})
|
||||
if economic_total > 0:
|
||||
compound_rate = ((economic_total / paid) ** (1.0 / year) - 1.0) * 100.0
|
||||
allowed_derived_values.append({
|
||||
"code": "compoundReturn",
|
||||
"productIndex": product_index,
|
||||
"policyYear": year,
|
||||
"value": f"{compound_rate:.2f}%",
|
||||
})
|
||||
if year == 20 and total and total > 0 and economic_total > 0:
|
||||
allowed_derived_values.append({
|
||||
"code": "year20Multiple",
|
||||
"productIndex": product_index,
|
||||
"value": f"{economic_total / total:.1f}x",
|
||||
})
|
||||
|
||||
benefit_rows = product.get("benefitRows") or []
|
||||
final_value = _number_or_none((benefit_rows[-1] if benefit_rows else {}).get("totalSurrenderValue"))
|
||||
if total and total > 0 and final_value is not None and final_value > 0:
|
||||
allowed_derived_values.append({
|
||||
"code": "finalValueMultiple",
|
||||
"productIndex": product_index,
|
||||
"value": f"{final_value / total:.1f}x",
|
||||
})
|
||||
|
||||
if len(products) > 1 and len(currencies) == 1 and all(value is not None for value in totals):
|
||||
allowed_derived_values.append({
|
||||
"code": "aggregateTotalPremium",
|
||||
"value": sum(totals),
|
||||
})
|
||||
|
||||
savings = next((item for item in products if item.get("kind") == "savings"), None)
|
||||
iul = next((item for item in products if item.get("kind") == "iul"), None)
|
||||
if savings and iul and len(currencies) == 1:
|
||||
withdrawal = next((
|
||||
row for row in savings.get("withdrawalRows") or []
|
||||
if (_number_or_none(row.get("annualWithdrawal")) or 0) > 0
|
||||
), None)
|
||||
annual_withdrawal = _number_or_none((withdrawal or {}).get("annualWithdrawal"))
|
||||
iul_premium = _number_or_none((iul.get("policy") or {}).get("annualPremium"))
|
||||
if annual_withdrawal is not None and iul_premium is not None:
|
||||
allowed_derived_values.append({
|
||||
"code": "portfolioAnnualRemainder",
|
||||
"value": annual_withdrawal - iul_premium,
|
||||
})
|
||||
|
||||
deck = {
|
||||
"id": f"deck_{uuid.uuid4().hex[:12]}",
|
||||
"generatedAt": __import__("datetime").datetime.now().isoformat(),
|
||||
@ -156,6 +275,7 @@ def _build_deck_contract(
|
||||
"scenario": resolved_scenario,
|
||||
"generationMode": resolved_mode,
|
||||
"comparison": comparison or {},
|
||||
"allowedDerivedValues": allowed_derived_values,
|
||||
"scenarioSlides": (
|
||||
(template_config or {}).get("scenarioPageSpecs")
|
||||
or build_scenario_slides(products, resolved_scenario)
|
||||
|
||||
@ -4,7 +4,7 @@ import uuid
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from flask import Blueprint, request, jsonify, make_response, send_file
|
||||
from insurance.middleware.auth_middleware import account_required as jwt_required
|
||||
from insurance.utils.response import success, error, ErrorCode
|
||||
|
||||
@ -817,7 +817,6 @@ def generate_ppt(session_id):
|
||||
session.latest_task_id = task_data["id"]
|
||||
_save_session(session)
|
||||
|
||||
from flask import make_response
|
||||
resp = make_response(jsonify({
|
||||
"code": 0,
|
||||
"data": {
|
||||
@ -825,6 +824,7 @@ def generate_ppt(session_id):
|
||||
"status": "queued",
|
||||
"sessionId": session_id,
|
||||
"scenario": scenario,
|
||||
"draftRevision": session.draft_revision or 1,
|
||||
"pollUrl": f"/insurance/workspace/tasks/{task_data['id']}",
|
||||
},
|
||||
}))
|
||||
|
||||
@ -492,22 +492,24 @@ def add_slide_cover(prs, deck, colors, meta):
|
||||
slide = add_blank_slide(prs)
|
||||
add_bg(slide, colors)
|
||||
|
||||
customer = deck.get("customer", {})
|
||||
products = deck.get("products", [])
|
||||
company = deck.get("company", {})
|
||||
customer = deck.get("customer") or {}
|
||||
products = deck.get("products") or []
|
||||
company = deck.get("company") or {}
|
||||
product = products[0] if products else {}
|
||||
s = _product_summary(product)
|
||||
|
||||
title_text = meta.get("title", "").replace("{{customerName}}", customer.get("name", "客户"))
|
||||
customer_name = str(customer.get("name") or "客户")
|
||||
title_text = str(meta.get("title") or "").replace("{{customerName}}", customer_name)
|
||||
if not title_text or "{{" in title_text:
|
||||
title_text = f"{customer.get('name', '客户')} 专属方案"
|
||||
title_text = f"{customer_name} 专属方案"
|
||||
|
||||
add_textbox(slide, Inches(0.8), Inches(1.2), Inches(11.7), Inches(0.8),
|
||||
title_text, size=38, bold=True, colors=colors)
|
||||
|
||||
subtitle = meta.get("subtitle", "").replace("{{productName}}", s["productName"])
|
||||
product_name = str(s.get("productName") or "")
|
||||
subtitle = str(meta.get("subtitle") or "").replace("{{productName}}", product_name)
|
||||
if not subtitle or "{{" in subtitle:
|
||||
subtitle = s["productName"] or "财富增值与传承方案"
|
||||
subtitle = product_name or "财富增值与传承方案"
|
||||
add_textbox(slide, Inches(0.8), Inches(2.2), Inches(11.7), Inches(0.5),
|
||||
subtitle, size=20, color=colors["muted"], colors=colors)
|
||||
|
||||
@ -535,10 +537,11 @@ def add_slide_company(prs, deck, colors, meta):
|
||||
slide = add_blank_slide(prs)
|
||||
add_bg(slide, colors)
|
||||
|
||||
company = deck.get("company", {})
|
||||
title = meta.get("title", "").replace("{{companyName}}", company.get("displayName", ""))
|
||||
company = deck.get("company") or {}
|
||||
company_name = str(company.get("displayName") or "")
|
||||
title = str(meta.get("title") or "").replace("{{companyName}}", company_name)
|
||||
if not title or "{{" in title:
|
||||
title = f"{company.get('displayName', '合作保司')} 公司介绍"
|
||||
title = f"{company_name or '合作保司'} 公司介绍"
|
||||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||||
|
||||
intro = company.get("companyIntro", "")
|
||||
|
||||
@ -264,7 +264,7 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]:
|
||||
):
|
||||
collision_rows += 1
|
||||
if comparable_rows >= 3 and collision_rows / comparable_rows >= 0.5:
|
||||
err(
|
||||
warn(
|
||||
"IUL_SURRENDER_EQUALS_DEATH_BENEFIT",
|
||||
"多数年度的退保价值与保额/身故利益完全相同,疑似表格列映射错误",
|
||||
"benefitRows", "benefitRows",
|
||||
@ -286,7 +286,7 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]:
|
||||
if annual_premium > 0 and len(positive_surrender) >= 3:
|
||||
tiny_values = [value for value in positive_surrender if value < annual_premium * 0.01]
|
||||
if len(tiny_values) / len(positive_surrender) >= 0.6:
|
||||
err(
|
||||
warn(
|
||||
"IUL_BENEFIT_VALUE_IMPLAUSIBLE",
|
||||
"多数退保价值不足年缴保费的 1%,疑似把年龄、页码或百分比识别为金额,请核对利益表",
|
||||
"benefitRows", "benefitRows",
|
||||
@ -305,7 +305,7 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]:
|
||||
# 解析声明的缴费年期
|
||||
stated_years = _extract_years(payment_period)
|
||||
if stated_years > 0 and detected_years > 0 and abs(detected_years - stated_years) > 1:
|
||||
err(
|
||||
warn(
|
||||
"IUL_PAY_TERM_MISMATCH",
|
||||
f"缴费年期不一致:声明 {stated_years} 年,数据检测 {detected_years} 年",
|
||||
"policy.paymentPeriod",
|
||||
@ -317,7 +317,7 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]:
|
||||
if isinstance(row, dict):
|
||||
age = _safe_number(row.get("age"))
|
||||
if age > 0 and (age < 0 or age > 150):
|
||||
err(
|
||||
warn(
|
||||
"IUL_AGE_OUT_OF_RANGE", f"年龄超出合理范围: {age}",
|
||||
f"benefitRows[{row_index}].age", "benefitRows",
|
||||
)
|
||||
@ -325,7 +325,7 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]:
|
||||
policy_year = _safe_number(row.get("policyYear"))
|
||||
expected_age = insured_age + policy_year - 1
|
||||
if age > 0 and insured_age > 0 and policy_year > 0 and abs(age - expected_age) > 1:
|
||||
err(
|
||||
warn(
|
||||
"IUL_AGE_YEAR_MISMATCH",
|
||||
f"第 {int(policy_year)} 保单年度年龄应约为 {int(expected_age)},实际为 {int(age)}",
|
||||
f"benefitRows[{row_index}].age", "benefitRows",
|
||||
|
||||
@ -324,10 +324,40 @@ const missingFieldsText = computed(() => {
|
||||
return `计划书原文未提供或未识别:${missing.map((key: string) => fieldLabels[key] || key).join('、')};核对后可保留为空`
|
||||
})
|
||||
const parseDiagnostics = computed(() => props.parsedFields?.meta || null)
|
||||
const hasManualChanges = computed(() => Object.keys(localFields.value).some((key) => {
|
||||
if (['meta', 'benefit_table', 'not_applicable_fields', 'sum_assured_not_applicable'].includes(key)) return false
|
||||
return localFields.value[key] !== props.parsedFields?.[key]
|
||||
}))
|
||||
const nonEditableCompareFields = new Set([
|
||||
'meta',
|
||||
'benefit_table',
|
||||
'not_applicable_fields',
|
||||
'sum_assured_not_applicable',
|
||||
])
|
||||
|
||||
function isEmptyFormValue(value: unknown): boolean {
|
||||
return value == null || value === ''
|
||||
}
|
||||
|
||||
function fieldValuesEqual(before: unknown, after: unknown): boolean {
|
||||
if (isEmptyFormValue(before) && isEmptyFormValue(after)) return true
|
||||
if (typeof before === 'number' || typeof after === 'number') {
|
||||
const beforeNumber = Number(before)
|
||||
const afterNumber = Number(after)
|
||||
if (Number.isFinite(beforeNumber) && Number.isFinite(afterNumber)) {
|
||||
return beforeNumber === afterNumber
|
||||
}
|
||||
}
|
||||
if (typeof before === 'object' || typeof after === 'object') {
|
||||
return JSON.stringify(before ?? null) === JSON.stringify(after ?? null)
|
||||
}
|
||||
return before === after
|
||||
}
|
||||
|
||||
function fieldWasManuallyChanged(key: string, after: unknown): boolean {
|
||||
if (nonEditableCompareFields.has(key)) return false
|
||||
return !fieldValuesEqual(props.parsedFields?.[key], after)
|
||||
}
|
||||
|
||||
const hasManualChanges = computed(() => Object.entries(localFields.value).some(
|
||||
([key, value]) => fieldWasManuallyChanged(key, value),
|
||||
))
|
||||
const provenanceEntries = computed<any[]>(() => Object.values(parseDiagnostics.value?.provenance || {}))
|
||||
const manualSourceCount = computed(() => provenanceEntries.value.filter(item => item?.source === 'manual_override').length)
|
||||
const derivedSourceCount = computed(() => provenanceEntries.value.filter(item => item?.source === 'system_derived').length)
|
||||
@ -406,7 +436,7 @@ function applyCaseData(data: any) {
|
||||
caseFileHash: data.fileHash || '',
|
||||
parseSnapshotHash: data.parseSnapshotHash || '',
|
||||
})
|
||||
if (data.parsedData) Object.assign(localFields.value, data.parsedData)
|
||||
if (data.parsedData) localFields.value = { ...emptyLocalFields(), ...data.parsedData }
|
||||
return true
|
||||
}
|
||||
if (status === 'failed') {
|
||||
@ -504,14 +534,32 @@ async function onConfirm() {
|
||||
if (!props.caseUploadId) return
|
||||
confirming.value = true
|
||||
try {
|
||||
// 确认前重新读取服务端版本,避免用 localStorage 中的旧哈希提交。
|
||||
const latestRes: any = await posterApi.getCaseUpload(props.caseUploadId)
|
||||
const latest = latestRes?.data
|
||||
if (!latest?.fileHash || !latest?.parseSnapshotHash) {
|
||||
throw new Error('最新解析版本加载失败,请重新解析计划书')
|
||||
}
|
||||
if (!['parsed', 'partial'].includes(latest.parseStatus)) {
|
||||
applyCaseData(latest)
|
||||
throw new Error('计划书尚未解析完成,请稍后重试')
|
||||
}
|
||||
if (
|
||||
latest.fileHash !== props.caseFileHash
|
||||
|| latest.parseSnapshotHash !== props.parseSnapshotHash
|
||||
) {
|
||||
applyCaseData(latest)
|
||||
emit('update:parse', { dataConfirmed: false })
|
||||
ElMessage.warning('解析结果已更新,请重新核对后再确认')
|
||||
return
|
||||
}
|
||||
|
||||
const confirmedFields = JSON.parse(JSON.stringify(localFields.value))
|
||||
const provenance = { ...(confirmedFields.meta?.provenance || {}) }
|
||||
for (const key of Object.keys(confirmedFields)) {
|
||||
if (key === 'meta' || key === 'benefit_table') continue
|
||||
const before = props.parsedFields?.[key]
|
||||
const after = confirmedFields[key]
|
||||
const bothEmpty = (before == null || before === '') && (after == null || after === '')
|
||||
if (!bothEmpty && before !== after) provenance[key] = { source: 'manual_override' }
|
||||
if (fieldWasManuallyChanged(key, confirmedFields[key])) {
|
||||
provenance[key] = { source: 'manual_override' }
|
||||
}
|
||||
}
|
||||
confirmedFields.meta = { ...(confirmedFields.meta || {}), provenance }
|
||||
if (hasManualChanges.value && !overrideReason.value.trim()) {
|
||||
@ -520,8 +568,8 @@ async function onConfirm() {
|
||||
}
|
||||
await posterApi.confirmCaseUpload(props.caseUploadId, {
|
||||
confirmedData: confirmedFields,
|
||||
fileHash: props.caseFileHash,
|
||||
parseSnapshotHash: props.parseSnapshotHash,
|
||||
fileHash: latest.fileHash,
|
||||
parseSnapshotHash: latest.parseSnapshotHash,
|
||||
overrideReason: overrideReason.value.trim(),
|
||||
})
|
||||
Object.assign(localFields.value, confirmedFields)
|
||||
|
||||
@ -30,6 +30,7 @@ export function useAutoSave(options: AutoSaveOptions) {
|
||||
const hasUnsavedChanges = ref(false)
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let workspaceGeneration = 0
|
||||
|
||||
/**
|
||||
* 触发防抖保存。
|
||||
@ -59,6 +60,7 @@ export function useAutoSave(options: AutoSaveOptions) {
|
||||
async function doSave(data: Record<string, any>): Promise<boolean> {
|
||||
if (saving.value) return false
|
||||
|
||||
const saveGeneration = workspaceGeneration
|
||||
saving.value = true
|
||||
conflictError.value = null
|
||||
|
||||
@ -68,6 +70,7 @@ export function useAutoSave(options: AutoSaveOptions) {
|
||||
expected_revision: draftRevision.value,
|
||||
}
|
||||
const res: any = await api.post(getEndpoint(), payload)
|
||||
if (saveGeneration !== workspaceGeneration) return false
|
||||
// 拦截器已解包 Axios 响应,res = {code: 0, data: {...}}
|
||||
if (res?.code === 0) {
|
||||
draftRevision.value = res.data?.draft_revision || draftRevision.value + 1
|
||||
@ -76,10 +79,20 @@ export function useAutoSave(options: AutoSaveOptions) {
|
||||
lastSaved.value = new Date()
|
||||
return true
|
||||
} else if (res?.code === 1001 && res?.message?.includes('冲突')) {
|
||||
syncDraftRevision(Number(res.data?.current_revision))
|
||||
conflictError.value = res.message
|
||||
hasUnsavedChanges.value = true
|
||||
return false
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (saveGeneration !== workspaceGeneration) return false
|
||||
const response = e?.response?.data
|
||||
if (response?.code === 1001 && response?.message?.includes('冲突')) {
|
||||
syncDraftRevision(Number(response.data?.current_revision))
|
||||
conflictError.value = response.message
|
||||
hasUnsavedChanges.value = true
|
||||
return false
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('自动保存失败:', e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
@ -101,6 +114,23 @@ export function useAutoSave(options: AutoSaveOptions) {
|
||||
hasUnsavedChanges.value = draftRevision.value > generatedRevision.value
|
||||
}
|
||||
|
||||
function syncDraftRevision(serverDraftRevision: number) {
|
||||
if (Number.isInteger(serverDraftRevision) && serverDraftRevision > 0) {
|
||||
draftRevision.value = serverDraftRevision
|
||||
}
|
||||
}
|
||||
|
||||
function resetForWorkspace(serverDraftRevision: number = 1, serverGeneratedRevision: number = 0) {
|
||||
workspaceGeneration += 1
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
conflictError.value = null
|
||||
lastSaved.value = null
|
||||
initRevisions(serverDraftRevision, serverGeneratedRevision)
|
||||
}
|
||||
|
||||
return {
|
||||
draftRevision,
|
||||
generatedRevision,
|
||||
@ -111,5 +141,7 @@ export function useAutoSave(options: AutoSaveOptions) {
|
||||
scheduleSave,
|
||||
saveNow,
|
||||
initRevisions,
|
||||
syncDraftRevision,
|
||||
resetForWorkspace,
|
||||
}
|
||||
}
|
||||
|
||||
@ -282,6 +282,40 @@ export function usePosterWorkspace() {
|
||||
return false
|
||||
}
|
||||
|
||||
function applyServerCaseUpload(caseUpload: any) {
|
||||
if (!caseUpload?.id) return
|
||||
|
||||
const serverFileHash = String(caseUpload.fileHash || '')
|
||||
const serverParseSnapshotHash = String(caseUpload.parseSnapshotHash || '')
|
||||
const sameParseVersion = Boolean(
|
||||
draft.value.caseFileHash
|
||||
&& draft.value.parseSnapshotHash
|
||||
&& draft.value.caseFileHash === serverFileHash
|
||||
&& draft.value.parseSnapshotHash === serverParseSnapshotHash,
|
||||
)
|
||||
const rawStatus = String(caseUpload.parseStatus || 'none')
|
||||
const status = rawStatus === 'pending' ? 'queued' : rawStatus
|
||||
const validStatuses: PosterDraft['parseStatus'][] = [
|
||||
'none', 'uploading', 'queued', 'parsing', 'parsed', 'partial', 'failed',
|
||||
]
|
||||
|
||||
draft.value.caseUploadId = Number(caseUpload.id)
|
||||
draft.value.caseFileHash = serverFileHash
|
||||
draft.value.parseSnapshotHash = serverParseSnapshotHash
|
||||
draft.value.parseStatus = validStatuses.includes(status as PosterDraft['parseStatus'])
|
||||
? status as PosterDraft['parseStatus']
|
||||
: 'none'
|
||||
draft.value.parseProgress = Number(caseUpload.parseProgress || 0)
|
||||
draft.value.parseMessage = String(caseUpload.parseMessage || '')
|
||||
draft.value.parseFailMessage = String(caseUpload.parseError || '')
|
||||
draft.value.dataConfirmed = Boolean(caseUpload.confirmedAt && caseUpload.snapshotId)
|
||||
|
||||
const serverFields = caseUpload.confirmedData || caseUpload.parsedData || {}
|
||||
if (caseUpload.confirmedData || !sameParseVersion || !Object.keys(draft.value.parsedFields || {}).length) {
|
||||
draft.value.parsedFields = serverFields
|
||||
}
|
||||
}
|
||||
|
||||
// ── 从后端 API 恢复 ──────────────────────
|
||||
async function restore(): Promise<boolean> {
|
||||
const urlRecordId = route.params.recordId as string
|
||||
@ -393,6 +427,18 @@ export function usePosterWorkspace() {
|
||||
if (record.extraData?.referenceImages) {
|
||||
draft.value.referenceImages = record.extraData.referenceImages
|
||||
}
|
||||
// localStorage 仅用于编辑草稿;计划书版本与解析结果始终以服务端为准。
|
||||
if (record.caseUploadId) {
|
||||
try {
|
||||
const caseRes: any = await api.get(`/poster/case-upload/${record.caseUploadId}`)
|
||||
applyServerCaseUpload(caseRes?.data ?? caseRes)
|
||||
} catch (caseError) {
|
||||
draft.value.caseFileHash = ''
|
||||
draft.value.parseSnapshotHash = ''
|
||||
draft.value.dataConfirmed = false
|
||||
console.warn('恢复计划书解析结果失败:', caseError)
|
||||
}
|
||||
}
|
||||
restored.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
@ -187,6 +187,8 @@ export function usePptWorkspace() {
|
||||
* 设置新的 session ID(上传成功后调用)。
|
||||
*/
|
||||
function setSession(id: string, step: number = 1) {
|
||||
draftRevision.value = 1
|
||||
generatedRevision.value = 0
|
||||
sessionId.value = id
|
||||
currentStep.value = step
|
||||
maxReachableStep.value = Math.max(maxReachableStep.value, step)
|
||||
|
||||
@ -195,6 +195,17 @@ function syncRenderDocument() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyServerRenderDocument(record: any) {
|
||||
const document = record?.document
|
||||
if (document?.schemaVersion !== 2) return
|
||||
const d = ws.draft.value
|
||||
d.renderDocument = document
|
||||
d.copyContent = document.copy || d.copyContent
|
||||
d.parsedFields = document.facts || d.parsedFields
|
||||
d.templateColorScheme = document.theme || d.templateColorScheme
|
||||
d.sections = document.sections || d.sections
|
||||
}
|
||||
|
||||
// ── 画布文字编辑(来自 HTML 海报的 contenteditable)────
|
||||
function onCanvasEdit(field: string, value: string) {
|
||||
const d = ws.draft.value
|
||||
@ -359,13 +370,7 @@ async function onGenerate() {
|
||||
})
|
||||
const record = res?.data
|
||||
d.taskRecordId = record?.id
|
||||
if (record?.document?.schemaVersion === 2) {
|
||||
d.renderDocument = record.document
|
||||
d.copyContent = record.document.copy || d.copyContent
|
||||
d.parsedFields = record.document.facts || d.parsedFields
|
||||
d.templateColorScheme = record.document.theme || d.templateColorScheme
|
||||
d.sections = record.document.sections || d.sections
|
||||
}
|
||||
applyServerRenderDocument(record)
|
||||
if (record?.id) {
|
||||
ws.setRecordId(record.id)
|
||||
d.taskStatus = 'queued'
|
||||
@ -395,9 +400,7 @@ function startPolling(id: number) {
|
||||
const status = record?.taskStatus || record?.task_status || 'generating'
|
||||
const progress = record?.taskProgress ?? record?.task_progress ?? 0
|
||||
const error = record?.taskError || record?.task_error || null
|
||||
if (record?.document?.schemaVersion === 2) {
|
||||
ws.draft.value.renderDocument = record.document
|
||||
}
|
||||
applyServerRenderDocument(record)
|
||||
|
||||
if (status === 'running' || status === 'pending') {
|
||||
ws.draft.value.taskStatus = status === 'pending' ? 'queued' : 'generating'
|
||||
@ -534,7 +537,13 @@ async function saveCompositeToServer(recordId: number) {
|
||||
return true
|
||||
} catch (e: any) {
|
||||
console.warn('最终海报保存失败:', e)
|
||||
throw new Error(`RENDER_UPLOAD_FAILED: ${e?.message || '最终海报回传失败'}`)
|
||||
const response = e?.response?.data
|
||||
const reasonCodes = Array.isArray(response?.data?.errors)
|
||||
? `(${response.data.errors.join('、')})`
|
||||
: ''
|
||||
throw new Error(
|
||||
`RENDER_UPLOAD_FAILED: ${response?.message || e?.message || '最终海报回传失败'}${reasonCodes}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -775,7 +784,7 @@ async function restoreWorkspace() {
|
||||
try {
|
||||
const recordRes: any = await posterApi.getRecord(pollId)
|
||||
const record = recordRes?.data ?? recordRes
|
||||
if (record?.document?.schemaVersion === 2) d.renderDocument = record.document
|
||||
applyServerRenderDocument(record)
|
||||
const blob = await posterApi.downloadBackground(pollId)
|
||||
const ready = await applyGeneratedBackground(
|
||||
blob,
|
||||
|
||||
@ -165,7 +165,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, provide, onUnmounted } from 'vue'
|
||||
import { computed, onMounted, ref, provide, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Bell, Clock, RefreshLeft, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { usePptWorkspace } from '@/composables/useWorkspace'
|
||||
@ -190,6 +190,25 @@ const mobileStepsOpen = ref(false)
|
||||
// 自动保存
|
||||
const autoSave = useAutoSave({ endpoint: () => `/workspace/ppt/workspaces/${sessionId.value}/draft` })
|
||||
|
||||
watch(
|
||||
() => sessionId.value,
|
||||
(nextSessionId, previousSessionId) => {
|
||||
if (nextSessionId !== previousSessionId) {
|
||||
autoSave.resetForWorkspace(ws.draftRevision.value, ws.generatedRevision.value)
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => ws.draftRevision.value, () => ws.generatedRevision.value],
|
||||
([draftRevision, generatedRevision]) => {
|
||||
if (sessionId.value) {
|
||||
autoSave.initRevisions(draftRevision, generatedRevision)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 子组件注册的 flush 回调(用于 beforeunload 时强制保存)
|
||||
const childFlushRef = ref<(() => Promise<void>) | null>(null)
|
||||
|
||||
@ -197,6 +216,7 @@ const childFlushRef = ref<(() => Promise<void>) | null>(null)
|
||||
provide('pptAutoSave', {
|
||||
scheduleSave: autoSave.scheduleSave,
|
||||
saveNow: autoSave.saveNow,
|
||||
syncDraftRevision: autoSave.syncDraftRevision,
|
||||
registerFlush: (fn: () => Promise<void>) => { childFlushRef.value = fn },
|
||||
})
|
||||
|
||||
|
||||
@ -1229,9 +1229,9 @@ function normalizeSmoker(raw: unknown): 'yes' | 'no' | 'unknown' {
|
||||
|
||||
function normalizeEditableRows(ext: any) {
|
||||
for (const row of getWithdrawalRows(ext)) {
|
||||
row.annual_withdrawal = row.annual_withdrawal ?? row.withdrawal_amount ?? 0
|
||||
row.total_withdrawn = row.total_withdrawn ?? row.cumulative_withdrawal ?? 0
|
||||
row.surrender_value_after = row.surrender_value_after ?? row.remaining_surrender_value ?? 0
|
||||
row.annual_withdrawal = row.annual_withdrawal ?? row.withdrawal_amount ?? null
|
||||
row.total_withdrawn = row.total_withdrawn ?? row.cumulative_withdrawal ?? null
|
||||
row.surrender_value_after = row.surrender_value_after ?? row.remaining_surrender_value ?? null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -139,7 +139,11 @@ import { pptApi } from '@/utils/ppt-api'
|
||||
import api from '@/utils/api'
|
||||
|
||||
// 自动保存(由父组件 PptPage 注入)
|
||||
const autoSave = inject<{ scheduleSave: (data: Record<string, any>) => void; saveNow: (data: Record<string, any>) => Promise<boolean> } | null>('pptAutoSave', null)
|
||||
const autoSave = inject<{
|
||||
scheduleSave: (data: Record<string, any>) => void
|
||||
saveNow: (data: Record<string, any>) => Promise<boolean>
|
||||
syncDraftRevision: (revision: number) => void
|
||||
} | null>('pptAutoSave', null)
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
@ -163,6 +167,7 @@ const resolvedScenario = ref('')
|
||||
const resolvedScenarioVersion = ref<any | null>(null)
|
||||
const scenarioMatchTrace = ref<Record<string, any> | null>(null)
|
||||
const configurationError = ref('')
|
||||
const confirmedSnapshotIds = ref<number[]>([])
|
||||
|
||||
// 监听配置变更,触发自动保存
|
||||
watch(selectedTemplateId, () => {
|
||||
@ -315,6 +320,7 @@ async function loadConfiguration() {
|
||||
companies.value = optionsRes?.data?.companies || []
|
||||
sessionFiles.value = sessionRes?.data?.files || []
|
||||
const snapshotIds = sessionRes?.data?.snapshot_ids || []
|
||||
confirmedSnapshotIds.value = snapshotIds
|
||||
const scenarioRes: any = await pptApi.resolveScenario(snapshotIds)
|
||||
resolvedScenario.value = scenarioRes?.data?.scenario || ''
|
||||
resolvedScenarioVersion.value = scenarioRes?.data?.scenarioVersion || null
|
||||
@ -368,13 +374,31 @@ watch(availableTemplates, (list) => {
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!selectedTemplateId.value) return
|
||||
if (!confirmedSnapshotIds.value.length) {
|
||||
ElMessage.error('请先返回核对数据并创建确认快照')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
try {
|
||||
if (autoSave) {
|
||||
const saved = await autoSave.saveNow({
|
||||
workflow_step: 'ready',
|
||||
draft_options: { templateId: selectedTemplateId.value },
|
||||
})
|
||||
if (!saved) {
|
||||
ElMessage.error('生成配置保存失败,请重试')
|
||||
return
|
||||
}
|
||||
}
|
||||
const res: any = await pptApi.generate(props.sessionId, {
|
||||
templateId: selectedTemplateId.value,
|
||||
scenario: currentScenario.value,
|
||||
snapshotIds: confirmedSnapshotIds.value,
|
||||
})
|
||||
const data = res?.data
|
||||
if (data?.draftRevision) {
|
||||
autoSave?.syncDraftRevision(data.draftRevision)
|
||||
}
|
||||
if (data?.taskId) {
|
||||
startPolling(data.taskId)
|
||||
} else if (data?.status === 'done') {
|
||||
|
||||
@ -139,6 +139,7 @@ export const pptApi = {
|
||||
companyId?: string
|
||||
style?: string
|
||||
scenario?: string
|
||||
snapshotIds?: number[]
|
||||
}) {
|
||||
return api.post(`/ppt/generate/${sessionId}`, params)
|
||||
},
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""统一任务接口响应结构回归测试。"""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from flask import Flask
|
||||
import jwt
|
||||
@ -11,6 +12,42 @@ from insurance.generation import task_service
|
||||
from insurance.generation.routes import workspace_bp
|
||||
|
||||
|
||||
def test_ppt_draft_revision_conflict_returns_current_revision(monkeypatch):
|
||||
session = SimpleNamespace(draft_revision=1)
|
||||
|
||||
class SessionQuery:
|
||||
def filter_by(self, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return session
|
||||
|
||||
model_module = ModuleType("insurance.models.ppt_session")
|
||||
model_module.PptSession = SimpleNamespace(query=SessionQuery())
|
||||
compat_module = ModuleType("insurance.db.compat")
|
||||
compat_module.db = SimpleNamespace(session=SimpleNamespace())
|
||||
monkeypatch.setitem(sys.modules, "insurance.models.ppt_session", model_module)
|
||||
monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["JWT_SECRET"] = "test-secret"
|
||||
app.config["GUEST_MODE"] = True
|
||||
app.register_blueprint(workspace_bp, url_prefix="/insurance/workspace")
|
||||
token = jwt.encode({"user_id": "user-1"}, "test-secret", algorithm="HS256")
|
||||
|
||||
response = app.test_client().post(
|
||||
"/insurance/workspace/ppt/workspaces/session-1/draft",
|
||||
json={"expected_revision": 3, "draft_options": {"templateId": "template-1"}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
body = response.get_json()
|
||||
assert body["code"] == 1001
|
||||
assert body["data"] == {"current_revision": 1, "requested_revision": 3}
|
||||
|
||||
|
||||
|
||||
def test_task_detail_returns_task_as_direct_data(monkeypatch):
|
||||
"""任务详情不能重复包裹 code/data,否则前端轮询读不到 status。"""
|
||||
monkeypatch.setattr(
|
||||
|
||||
@ -3,6 +3,8 @@ import zipfile
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api"))
|
||||
|
||||
@ -104,6 +106,144 @@ def test_manifest_reconciliation_reports_missing_field_key(tmp_path):
|
||||
assert report["missingEntries"][0]["fieldPath"] == "policy.annualPremium"
|
||||
|
||||
|
||||
def test_manifest_reconciliation_allows_frozen_derived_amounts(tmp_path):
|
||||
from insurance.generation.reconciliation import reconcile_pptx
|
||||
|
||||
output = tmp_path / "derived.pptx"
|
||||
_pptx(output, "USD 100,000 USD 50,000")
|
||||
contract = {
|
||||
"products": [{"policy": {"currency": "USD", "annualPremium": 100000}}],
|
||||
"allowedDerivedValues": [{"code": "portfolioAnnualRemainder", "value": 50000}],
|
||||
"reconciliationManifest": [{
|
||||
"key": "products.0.policy.annualPremium",
|
||||
"fieldPath": "policy.annualPremium",
|
||||
"pageTypes": ["policy_summary"],
|
||||
"currency": "USD",
|
||||
"value": 100000,
|
||||
}],
|
||||
}
|
||||
|
||||
report = reconcile_pptx(contract, str(output))
|
||||
|
||||
assert report["status"] == "passed"
|
||||
assert report["unexpectedNumbers"] == []
|
||||
|
||||
_pptx(output, "USD 100,000 USD 60,000")
|
||||
invalid = reconcile_pptx(contract, str(output))
|
||||
assert invalid["status"] == "failed"
|
||||
assert "60000" in invalid["unexpectedNumbers"]
|
||||
|
||||
|
||||
def test_deck_contract_freezes_comprehensive_plan_derived_amounts():
|
||||
from insurance.ppt.renderer import _build_deck_contract
|
||||
|
||||
savings = {
|
||||
"kind": "savings",
|
||||
"policy": {"currency": "USD", "contractualTotalPremium": 750000},
|
||||
"benefitRows": [{
|
||||
"policyYear": 10,
|
||||
"reversionaryBonus": 120000,
|
||||
"terminalDividend": 80000,
|
||||
}],
|
||||
"withdrawalRows": [{
|
||||
"policyYear": 10,
|
||||
"annualWithdrawal": 200000,
|
||||
"cumulativeWithdrawal": 400000,
|
||||
"surrenderValueAfter": 600000,
|
||||
}],
|
||||
}
|
||||
iul = {
|
||||
"kind": "iul",
|
||||
"policy": {
|
||||
"currency": "USD",
|
||||
"annualPremium": 150000,
|
||||
"contractualTotalPremium": 1500000,
|
||||
},
|
||||
"benefitRows": [],
|
||||
"withdrawalRows": [],
|
||||
}
|
||||
|
||||
deck = _build_deck_contract(savings, all_products=[savings, iul])
|
||||
derived = {item["code"]: item["value"] for item in deck["allowedDerivedValues"]}
|
||||
|
||||
assert derived["nonGuaranteedBenefit"] == 200000
|
||||
assert derived["withdrawalEconomicTotal"] == 1000000
|
||||
assert derived["aggregateTotalPremium"] == 2250000
|
||||
assert derived["portfolioAnnualRemainder"] == 50000
|
||||
|
||||
|
||||
def test_comprehensive_ppt_derived_amounts_pass_output_reconciliation(tmp_path):
|
||||
pytest.importorskip("pptx")
|
||||
from insurance.generation.reconciliation import reconcile_pptx
|
||||
from insurance.ppt.renderer import _build_deck_contract
|
||||
from insurance.ppt.scripts.fast_pptx_renderer import render_deck
|
||||
|
||||
years = [1, 5, 10, 20, 30]
|
||||
savings = {
|
||||
"kind": "savings",
|
||||
"productName": "储蓄计划",
|
||||
"insured": {"name": "客户", "age": 45, "gender": "male"},
|
||||
"policy": {
|
||||
"currency": "USD",
|
||||
"annualPremium": 150000,
|
||||
"payYears": 5,
|
||||
"contractualTotalPremium": 750000,
|
||||
},
|
||||
"benefitRows": [{
|
||||
"policyYear": year,
|
||||
"age": 45 + year,
|
||||
"totalPremiumPaid": min(year, 5) * 150000,
|
||||
"guaranteedCashValue": year * 70000,
|
||||
"reversionaryBonus": year * 10000,
|
||||
"terminalDividend": year * 5000,
|
||||
"totalSurrenderValue": year * 120000,
|
||||
"sourcePage": 8,
|
||||
} for year in years],
|
||||
"withdrawalRows": [{
|
||||
"policyYear": year,
|
||||
"age": 45 + year,
|
||||
"annualWithdrawal": 200000,
|
||||
"cumulativeWithdrawal": year * 200000,
|
||||
"surrenderValueAfter": year * 80000,
|
||||
} for year in [10, 20, 30]],
|
||||
}
|
||||
iul = {
|
||||
"kind": "iul",
|
||||
"productName": "IUL 计划",
|
||||
"insured": {"name": "客户", "age": 45, "gender": "male"},
|
||||
"policy": {
|
||||
"currency": "USD",
|
||||
"annualPremium": 150000,
|
||||
"payYears": 10,
|
||||
"contractualTotalPremium": 1500000,
|
||||
"sumInsured": 2000000,
|
||||
},
|
||||
"benefitRows": [{
|
||||
"policyYear": year,
|
||||
"age": 45 + year,
|
||||
"totalPremiumPaid": min(year, 10) * 150000,
|
||||
"guaranteedCashValue": year * 60000,
|
||||
"totalSurrenderValue": year * 100000,
|
||||
"nonGuaranteedDeathBenefit": 2000000 + year * 10000,
|
||||
"sourcePage": 10,
|
||||
} for year in years],
|
||||
"withdrawalRows": [],
|
||||
}
|
||||
deck = _build_deck_contract(
|
||||
savings,
|
||||
all_products=[savings, iul],
|
||||
scenario="savings_iul_comprehensive",
|
||||
generation_mode="portfolio",
|
||||
)
|
||||
output = tmp_path / "comprehensive.pptx"
|
||||
|
||||
result = render_deck(deck, str(output))
|
||||
report = reconcile_pptx(deck, str(output))
|
||||
|
||||
assert result["ok"] is True
|
||||
assert report["status"] == "passed", report
|
||||
|
||||
|
||||
def test_final_poster_export_reconciles_dom_sections_text_and_png_size():
|
||||
from insurance.generation.reconciliation import reconcile_poster_export
|
||||
|
||||
@ -148,6 +288,49 @@ def test_final_poster_export_reconciles_dom_sections_text_and_png_size():
|
||||
assert report["domOutput"] == {"width": 1024, "height": 1536}
|
||||
|
||||
|
||||
def test_final_poster_export_does_not_require_hidden_optional_benefits():
|
||||
from insurance.generation.reconciliation import reconcile_poster_export
|
||||
|
||||
visible_text = "专属保障方案 35岁 HKD 100,000 联系顾问"
|
||||
document = {
|
||||
"formatId": "single_2_3",
|
||||
"revision": 1,
|
||||
"copy": {"headline": "专属保障方案", "call_to_action": "联系顾问"},
|
||||
"summary": {"age": 35, "currency": "HKD", "annual_premium": 100000},
|
||||
"sections": [
|
||||
{"id": "hero", "visible": True},
|
||||
{"id": "summary", "visible": True},
|
||||
{"id": "benefits", "visible": False},
|
||||
{"id": "features", "visible": False},
|
||||
{"id": "cta", "visible": True},
|
||||
],
|
||||
}
|
||||
manifest = {
|
||||
"version": "poster-export-manifest-v1",
|
||||
"formatId": "single_2_3",
|
||||
"documentRevision": 1,
|
||||
"visibleText": visible_text,
|
||||
"visibleTextSha256": hashlib.sha256(visible_text.encode("utf-8")).hexdigest(),
|
||||
"canvas": {"scrollWidth": 512, "scrollHeight": 768, "pixelRatio": 2},
|
||||
"sections": [
|
||||
{"id": "hero", "present": True, "textLength": 6},
|
||||
{"id": "summary", "present": True, "textLength": 16},
|
||||
{"id": "cta", "present": True, "textLength": 4},
|
||||
],
|
||||
"overflowIssues": [],
|
||||
}
|
||||
|
||||
report = reconcile_poster_export(
|
||||
{"age": 35, "currency": "HKD", "annual_premium": 100000},
|
||||
document,
|
||||
manifest,
|
||||
{"width": 1024, "height": 1536, "format": "PNG"},
|
||||
)
|
||||
|
||||
assert report["status"] == "passed"
|
||||
assert report["missingSections"] == []
|
||||
|
||||
|
||||
def test_final_poster_export_blocks_missing_section_overflow_and_wrong_size():
|
||||
from insurance.generation.reconciliation import reconcile_poster_export
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
|
||||
def _record(tmp_path: Path, *, status="parsed", parsed=None):
|
||||
@ -63,6 +64,33 @@ def test_service_confirmation_cannot_be_bypassed_by_direct_request(tmp_path, mon
|
||||
assert result["data"]["errorCode"] == "PLAN_DATA_EMPTY"
|
||||
|
||||
|
||||
def test_reparse_requeues_orphaned_queued_case(tmp_path, monkeypatch):
|
||||
from insurance.poster import service as service_module
|
||||
|
||||
source = tmp_path / "plan.pdf"
|
||||
source.write_bytes(b"pdf")
|
||||
record = SimpleNamespace(
|
||||
id=63,
|
||||
user_id="user-1",
|
||||
source_file_url=str(source),
|
||||
parse_status="queued",
|
||||
to_dict=lambda: {"id": 63, "parseStatus": "queued"},
|
||||
)
|
||||
fake_model = SimpleNamespace(query=SimpleNamespace(get=lambda _record_id: record))
|
||||
monkeypatch.setattr(service_module, "PosterCaseUpload", fake_model)
|
||||
|
||||
submitted = []
|
||||
tasks_module = ModuleType("insurance.poster.tasks")
|
||||
tasks_module.start_case_parse_task = lambda record_id: submitted.append(record_id) or True
|
||||
monkeypatch.setitem(sys.modules, "insurance.poster.tasks", tasks_module)
|
||||
|
||||
result = service_module.PosterService().reparse_case_upload(63, "user-1")
|
||||
|
||||
assert result["code"] == 0
|
||||
assert result["message"] == "解析任务已重新入队"
|
||||
assert submitted == [63]
|
||||
|
||||
|
||||
def test_confirmation_preserves_zero_but_requires_positive_premium(tmp_path):
|
||||
from insurance.plan_data.validators import parse_snapshot_hash, validate_case_confirmation
|
||||
|
||||
@ -108,6 +136,26 @@ def test_confirmation_and_generation_bind_file_parse_and_payload_hashes(tmp_path
|
||||
assert any(issue.code == "CONFIRMED_DATA_CHANGED" for issue in result.issues)
|
||||
|
||||
|
||||
def test_confirmation_does_not_require_evidence_or_optional_projection_data(tmp_path):
|
||||
from insurance.plan_data.validators import parse_snapshot_hash, validate_case_confirmation
|
||||
|
||||
record = _record(tmp_path)
|
||||
data = _valid_data()
|
||||
data["annual_premium"] = 10000
|
||||
data["surrender_value_10"] = None
|
||||
data["benefit_table"] = []
|
||||
data["meta"]["provenance"] = {}
|
||||
|
||||
result = validate_case_confirmation(record, {
|
||||
"confirmedData": data,
|
||||
"fileHash": record.file_hash,
|
||||
"parseSnapshotHash": parse_snapshot_hash(record),
|
||||
}, {"productName": data["product_name"]})
|
||||
|
||||
assert result.valid
|
||||
assert not any(issue.code == "CRITICAL_EVIDENCE_MISSING" for issue in result.issues)
|
||||
|
||||
|
||||
def test_product_mismatch_and_unresolved_conflict_fail_closed(tmp_path):
|
||||
from insurance.plan_data.validators import parse_snapshot_hash, validate_case_confirmation
|
||||
|
||||
|
||||
@ -8,6 +8,18 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api"))
|
||||
|
||||
|
||||
def test_generate_ppt_does_not_shadow_module_jsonify():
|
||||
import symtable
|
||||
|
||||
route_path = Path(__file__).resolve().parents[1] / "api/insurance/ppt/routes.py"
|
||||
module = symtable.symtable(route_path.read_text(encoding="utf-8"), str(route_path), "exec")
|
||||
generate_ppt = next(child for child in module.get_children() if child.get_name() == "generate_ppt")
|
||||
jsonify = generate_ppt.lookup("jsonify")
|
||||
|
||||
assert jsonify.is_global()
|
||||
assert not jsonify.is_local()
|
||||
|
||||
|
||||
def _evidence(document_id=1, page=2):
|
||||
return {
|
||||
"documentId": document_id,
|
||||
@ -72,6 +84,35 @@ def test_legacy_conversion_uses_field_values_and_never_derives_contract_total():
|
||||
assert plan_data["benefitScenarios"][0]["rows"][0]["totalSurrenderValue"]["evidence"]
|
||||
|
||||
|
||||
def test_poster_flat_fields_survive_snapshot_conversion():
|
||||
from insurance.plan_data.conversion import legacy_to_plan_data
|
||||
from insurance.plan_data.snapshot_validation import validate_plan_data
|
||||
|
||||
poster_data = {
|
||||
"product_name": "测试重疾计划",
|
||||
"plan_type": "ci",
|
||||
"age": 38,
|
||||
"gender": "female",
|
||||
"smoking_status": "no",
|
||||
"currency": "HKD",
|
||||
"sum_assured": 1_000_000,
|
||||
"annual_premium": 25_000,
|
||||
"premium_term": 20,
|
||||
"meta": {"evidence": []},
|
||||
}
|
||||
|
||||
plan_data = legacy_to_plan_data(poster_data)
|
||||
result = validate_plan_data(plan_data, for_confirmation=True)
|
||||
|
||||
assert plan_data["identity"]["insuredAge"]["value"] == 38
|
||||
assert plan_data["identity"]["insuredGender"]["value"] == "female"
|
||||
assert plan_data["identity"]["insuredSmoker"]["value"] == "no"
|
||||
assert plan_data["policy"]["currency"]["value"] == "HKD"
|
||||
assert plan_data["policy"]["sumAssured"]["value"] == 1_000_000
|
||||
assert plan_data["policy"]["premiumPaymentPeriod"]["value"] == 20
|
||||
assert result["blockingCount"] == 0
|
||||
|
||||
|
||||
def test_projection_uses_confirmed_table_milestones_and_keeps_unknown_null():
|
||||
from insurance.plan_data.conversion import legacy_to_plan_data
|
||||
from insurance.plan_data.projections import to_deck_contract, to_poster_projection
|
||||
@ -174,7 +215,7 @@ def test_migration_037_and_snapshot_lifecycle_are_versioned_and_owned():
|
||||
} <= tables
|
||||
|
||||
|
||||
def test_confirmation_fails_closed_when_amount_evidence_is_missing():
|
||||
def test_confirmation_warns_when_critical_amount_evidence_is_missing():
|
||||
from insurance.plan_data.conversion import legacy_to_plan_data
|
||||
from insurance.plan_data.snapshot_validation import validate_plan_data
|
||||
|
||||
@ -182,10 +223,49 @@ def test_confirmation_fails_closed_when_amount_evidence_is_missing():
|
||||
data["meta"]["evidence"] = []
|
||||
result = validate_plan_data(legacy_to_plan_data(data), for_confirmation=True)
|
||||
|
||||
assert result["blockingCount"] >= 2
|
||||
assert result["blockingCount"] == 0
|
||||
assert result["warningCount"] == 1
|
||||
assert any(item["code"] == "CRITICAL_EVIDENCE_MISSING" for item in result["issues"])
|
||||
|
||||
|
||||
def test_optional_benefit_and_withdrawal_data_do_not_block_confirmation():
|
||||
from insurance.plan_data.conversion import legacy_to_plan_data
|
||||
from insurance.plan_data.snapshot_validation import validate_plan_data
|
||||
|
||||
data = _legacy_data()
|
||||
data["meta"]["evidence"] = [
|
||||
{"fieldPath": "policy.annual_premium", "evidence": _evidence(1, 2)},
|
||||
]
|
||||
data["withdrawal_illustration"] = [{
|
||||
"policy_year": 10,
|
||||
"annual_withdrawal": 50000,
|
||||
"surrender_value_after": 300000,
|
||||
}]
|
||||
result = validate_plan_data(legacy_to_plan_data(data), for_confirmation=True)
|
||||
|
||||
assert result["blockingCount"] == 0
|
||||
assert not any(
|
||||
item["code"] == "CRITICAL_EVIDENCE_MISSING"
|
||||
and (
|
||||
".benefitScenarios." in item["path"]
|
||||
or ".withdrawals." in item["path"]
|
||||
)
|
||||
for item in result["issues"]
|
||||
)
|
||||
|
||||
|
||||
def test_missing_optional_projection_sections_do_not_block_confirmation():
|
||||
from insurance.plan_data.conversion import legacy_to_plan_data
|
||||
from insurance.plan_data.snapshot_validation import validate_plan_data
|
||||
|
||||
data = _legacy_data()
|
||||
data["benefit_illustration"] = []
|
||||
data["withdrawal_illustration"] = []
|
||||
result = validate_plan_data(legacy_to_plan_data(data), for_confirmation=True)
|
||||
|
||||
assert result["blockingCount"] == 0
|
||||
|
||||
|
||||
def test_confirmation_does_not_treat_source_labels_as_amounts():
|
||||
from insurance.plan_data.conversion import legacy_to_plan_data
|
||||
from insurance.plan_data.snapshot_validation import validate_plan_data
|
||||
|
||||
@ -91,6 +91,15 @@ def test_failed_client_composition_reuses_existing_background_and_exposes_stage_
|
||||
assert "cacheBust: false" in exporter
|
||||
|
||||
|
||||
def test_poster_polling_keeps_server_normalized_sections_and_reports_422_reason():
|
||||
page = (ROOT / "frontend/src/pages/PosterPage.vue").read_text(encoding="utf-8")
|
||||
|
||||
assert "function applyServerRenderDocument" in page
|
||||
assert page.count("applyServerRenderDocument(record)") >= 3
|
||||
assert "d.sections = document.sections || d.sections" in page
|
||||
assert "response?.data?.errors" in page
|
||||
|
||||
|
||||
def test_final_client_export_submits_dom_manifest_before_download():
|
||||
page = (ROOT / "frontend/src/pages/PosterPage.vue").read_text(encoding="utf-8")
|
||||
exporter = (ROOT / "frontend/src/utils/poster-exporter.ts").read_text(encoding="utf-8")
|
||||
|
||||
@ -713,7 +713,7 @@ def test_iul_key_fields_accept_raw_and_normalized_names_without_data_loss():
|
||||
assert plan["benefitRows"][1]["totalSurrenderValue"] == 76800
|
||||
|
||||
|
||||
def test_iul_implausibly_tiny_benefit_values_block_generation():
|
||||
def test_iul_implausibly_tiny_benefit_values_warn_without_blocking_generation():
|
||||
from insurance.ppt.validator import validate_formal_iul_plan
|
||||
|
||||
issues = validate_formal_iul_plan({
|
||||
@ -732,7 +732,7 @@ def test_iul_implausibly_tiny_benefit_values_block_generation():
|
||||
})
|
||||
|
||||
assert any(
|
||||
issue.code == "IUL_BENEFIT_VALUE_IMPLAUSIBLE" and issue.level == "error"
|
||||
issue.code == "IUL_BENEFIT_VALUE_IMPLAUSIBLE" and issue.level == "warn"
|
||||
for issue in issues
|
||||
)
|
||||
|
||||
@ -864,7 +864,7 @@ def test_brand_policy_keeps_company_rules_per_company():
|
||||
assert policy["companyPolicyById"]["manulife"]["displayName"] == "宏X"
|
||||
|
||||
|
||||
def test_iul_surrender_value_cannot_repeat_death_benefit_for_most_rows():
|
||||
def test_iul_surrender_value_matching_death_benefit_is_non_blocking_warning():
|
||||
from insurance.ppt.validator import validate_formal_iul_plan
|
||||
|
||||
issues = validate_formal_iul_plan({
|
||||
@ -888,7 +888,7 @@ def test_iul_surrender_value_cannot_repeat_death_benefit_for_most_rows():
|
||||
})
|
||||
|
||||
assert any(
|
||||
issue.code == "IUL_SURRENDER_EQUALS_DEATH_BENEFIT" and issue.level == "error"
|
||||
issue.code == "IUL_SURRENDER_EQUALS_DEATH_BENEFIT" and issue.level == "warn"
|
||||
for issue in issues
|
||||
)
|
||||
|
||||
@ -1674,6 +1674,49 @@ def test_poster_source_polling_uses_backend_progress_without_local_timeout():
|
||||
assert "网络连接不稳定,后台仍在解析" in source
|
||||
|
||||
|
||||
def test_poster_confirmation_refreshes_server_hashes_before_submit():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
workspace = (root / "frontend/src/composables/usePosterWorkspace.ts").read_text(encoding="utf-8")
|
||||
source = (root / "frontend/src/components/poster/workspace/PosterSourcePanel.vue").read_text(encoding="utf-8")
|
||||
|
||||
assert "api.get(`/poster/case-upload/${record.caseUploadId}`)" in workspace
|
||||
assert "draft.value.caseFileHash = serverFileHash" in workspace
|
||||
assert "draft.value.parseSnapshotHash = serverParseSnapshotHash" in workspace
|
||||
assert "posterApi.getCaseUpload(props.caseUploadId)" in source
|
||||
assert "latest.fileHash !== props.caseFileHash" in source
|
||||
assert "latest.parseSnapshotHash !== props.parseSnapshotHash" in source
|
||||
assert "fileHash: latest.fileHash" in source
|
||||
assert "parseSnapshotHash: latest.parseSnapshotHash" in source
|
||||
|
||||
|
||||
def test_ppt_generation_response_resynchronizes_auto_save_revision():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
routes = (root / "api/insurance/ppt/routes.py").read_text(encoding="utf-8")
|
||||
page = (root / "frontend/src/pages/PptPage.vue").read_text(encoding="utf-8")
|
||||
generate = (root / "frontend/src/pages/components/ppt/PptGenerate.vue").read_text(encoding="utf-8")
|
||||
auto_save = (root / "frontend/src/composables/useAutoSave.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert '"draftRevision": session.draft_revision or 1' in routes
|
||||
assert "syncDraftRevision: autoSave.syncDraftRevision" in page
|
||||
assert "autoSave?.syncDraftRevision(data.draftRevision)" in generate
|
||||
assert "function syncDraftRevision(serverDraftRevision: number)" in auto_save
|
||||
|
||||
|
||||
def test_ppt_auto_save_resets_and_recovers_revision_when_workspace_changes():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
page = (root / "frontend/src/pages/PptPage.vue").read_text(encoding="utf-8")
|
||||
workspace = (root / "frontend/src/composables/useWorkspace.ts").read_text(encoding="utf-8")
|
||||
auto_save = (root / "frontend/src/composables/useAutoSave.ts").read_text(encoding="utf-8")
|
||||
routes = (root / "api/insurance/generation/routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "autoSave.resetForWorkspace" in page
|
||||
assert "draftRevision.value = 1" in workspace
|
||||
assert "response.data?.current_revision" in auto_save
|
||||
assert "saveGeneration !== workspaceGeneration" in auto_save
|
||||
assert '"current_revision": session.draft_revision' in routes
|
||||
assert '}), 409' in routes
|
||||
|
||||
|
||||
def test_poster_case_serializes_parsed_data_and_hides_unsafe_errors():
|
||||
from insurance.models.poster_case_upload import PosterCaseUpload
|
||||
|
||||
|
||||
@ -422,3 +422,60 @@ def test_generate_ppt_task_marks_failure_and_syncs_workspace(monkeypatch):
|
||||
assert session.workflow_step == "generating"
|
||||
assert any(update.get("status") == "failed" for update in updates)
|
||||
assert synced == [task]
|
||||
|
||||
|
||||
def test_parse_poster_case_retries_when_database_connection_is_closed(monkeypatch):
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
celery_module = types.ModuleType("celery")
|
||||
|
||||
def shared_task(*_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
celery_module.shared_task = shared_task
|
||||
monkeypatch.setitem(sys.modules, "celery", celery_module)
|
||||
sys.modules.pop("insurance.generation.celery_tasks", None)
|
||||
from insurance.generation import celery_tasks
|
||||
|
||||
class DisconnectingSession:
|
||||
def __init__(self):
|
||||
self.rollbacks = 0
|
||||
self.removals = 0
|
||||
|
||||
def execute(self, *_args, **_kwargs):
|
||||
raise OperationalError(
|
||||
"UPDATE poster_case_uploads",
|
||||
{"id": 63},
|
||||
RuntimeError("server closed the connection unexpectedly"),
|
||||
connection_invalidated=True,
|
||||
)
|
||||
|
||||
def rollback(self):
|
||||
self.rollbacks += 1
|
||||
|
||||
def remove(self):
|
||||
self.removals += 1
|
||||
|
||||
def commit(self):
|
||||
raise AssertionError("断线后不应提交领取事务")
|
||||
|
||||
session = DisconnectingSession()
|
||||
fake_db = types.SimpleNamespace(session=session, text=lambda statement: statement)
|
||||
compat_module = types.ModuleType("insurance.db.compat")
|
||||
compat_module.db = fake_db
|
||||
monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module)
|
||||
|
||||
retries = []
|
||||
task_context = types.SimpleNamespace(
|
||||
request=types.SimpleNamespace(retries=0),
|
||||
max_retries=5,
|
||||
retry=lambda **kwargs: retries.append(kwargs),
|
||||
)
|
||||
|
||||
celery_tasks.parse_poster_case_task(task_context, 63)
|
||||
|
||||
assert session.rollbacks == 1
|
||||
assert session.removals == 1
|
||||
assert len(retries) == 1
|
||||
assert retries[0]["countdown"] == 5
|
||||
assert isinstance(retries[0]["exc"], OperationalError)
|
||||
|
||||
@ -103,6 +103,30 @@ def test_renderer_uses_builtin_template_frames_without_sample_copy(
|
||||
assert (pictures > 0) is expect_picture
|
||||
|
||||
|
||||
def test_cover_renderer_allows_null_optional_display_fields(tmp_path):
|
||||
from insurance.ppt.scripts.fast_pptx_renderer import render_deck
|
||||
|
||||
output_path = tmp_path / "null-cover-fields.pptx"
|
||||
result = render_deck({
|
||||
"customer": {"name": None},
|
||||
"products": [{
|
||||
"kind": "savings",
|
||||
"productName": None,
|
||||
"policy": {},
|
||||
}],
|
||||
"company": {"displayName": None},
|
||||
"scenarioSlides": [{
|
||||
"pageType": "cover",
|
||||
"title": "{{customerName}} 专属方案",
|
||||
"subtitle": "{{productName}}",
|
||||
}],
|
||||
}, str(output_path), "deepblue")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["slides"] == 1
|
||||
assert output_path.is_file()
|
||||
|
||||
|
||||
def test_renderer_rejects_uploaded_template_with_too_few_frames(tmp_path):
|
||||
from insurance.ppt.scripts.fast_pptx_renderer import render_deck
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user