08-4 修复第二版

This commit is contained in:
wsb1224 2026-08-04 10:44:34 +08:00
parent 6d0d668323
commit 9ef2b24098
29 changed files with 948 additions and 99 deletions

View File

@ -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

View File

@ -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()

View File

@ -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 = []

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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 = "任务已提交,等待解析..."

View File

@ -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)

View File

@ -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']}",
},
}))

View File

@ -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", "")

View File

@ -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",

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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,

View File

@ -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 },
})

View File

@ -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
}
}

View File

@ -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') {

View File

@ -139,6 +139,7 @@ export const pptApi = {
companyId?: string
style?: string
scenario?: string
snapshotIds?: number[]
}) {
return api.post(`/ppt/generate/${sessionId}`, params)
},

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -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