diff --git a/.env.example b/.env.example index 45fd0ca..3c80755 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api/insurance/generation/celery_tasks.py b/api/insurance/generation/celery_tasks.py index 00e5e99..8bbb06e 100644 --- a/api/insurance/generation/celery_tasks.py +++ b/api/insurance/generation/celery_tasks.py @@ -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() diff --git a/api/insurance/generation/reconciliation.py b/api/insurance/generation/reconciliation.py index c9881ef..c4aa03c 100644 --- a/api/insurance/generation/reconciliation.py +++ b/api/insurance/generation/reconciliation.py @@ -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 = [] diff --git a/api/insurance/generation/routes.py b/api/insurance/generation/routes.py index 80699ba..fdb30ad 100644 --- a/api/insurance/generation/routes.py +++ b/api/insurance/generation/routes.py @@ -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) diff --git a/api/insurance/plan_data/conversion.py b/api/insurance/plan_data/conversion.py index 5243d98..a7e880a 100644 --- a/api/insurance/plan_data/conversion.py +++ b/api/insurance/plan_data/conversion.py @@ -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") diff --git a/api/insurance/plan_data/snapshot_validation.py b/api/insurance/plan_data/snapshot_validation.py index 4c69511..6a19732 100644 --- a/api/insurance/plan_data/snapshot_validation.py +++ b/api/insurance/plan_data/snapshot_validation.py @@ -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 diff --git a/api/insurance/plan_data/validators.py b/api/insurance/plan_data/validators.py index 2a29dc4..5a9b20b 100644 --- a/api/insurance/plan_data/validators.py +++ b/api/insurance/plan_data/validators.py @@ -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 diff --git a/api/insurance/poster/service.py b/api/insurance/poster/service.py index c4a97ac..875e211 100644 --- a/api/insurance/poster/service.py +++ b/api/insurance/poster/service.py @@ -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 = "任务已提交,等待解析..." diff --git a/api/insurance/ppt/renderer.py b/api/insurance/ppt/renderer.py index 22cb5b7..bc795a4 100644 --- a/api/insurance/ppt/renderer.py +++ b/api/insurance/ppt/renderer.py @@ -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) diff --git a/api/insurance/ppt/routes.py b/api/insurance/ppt/routes.py index 4a15b73..8792704 100644 --- a/api/insurance/ppt/routes.py +++ b/api/insurance/ppt/routes.py @@ -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']}", }, })) diff --git a/api/insurance/ppt/scripts/fast_pptx_renderer.py b/api/insurance/ppt/scripts/fast_pptx_renderer.py index 9fa91da..f815230 100644 --- a/api/insurance/ppt/scripts/fast_pptx_renderer.py +++ b/api/insurance/ppt/scripts/fast_pptx_renderer.py @@ -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", "") diff --git a/api/insurance/ppt/validator.py b/api/insurance/ppt/validator.py index 04abaef..74919be 100644 --- a/api/insurance/ppt/validator.py +++ b/api/insurance/ppt/validator.py @@ -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", diff --git a/frontend/src/components/poster/workspace/PosterSourcePanel.vue b/frontend/src/components/poster/workspace/PosterSourcePanel.vue index 5be4a34..f7f3405 100644 --- a/frontend/src/components/poster/workspace/PosterSourcePanel.vue +++ b/frontend/src/components/poster/workspace/PosterSourcePanel.vue @@ -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(() => 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) diff --git a/frontend/src/composables/useAutoSave.ts b/frontend/src/composables/useAutoSave.ts index 512b757..de2e0f4 100644 --- a/frontend/src/composables/useAutoSave.ts +++ b/frontend/src/composables/useAutoSave.ts @@ -30,6 +30,7 @@ export function useAutoSave(options: AutoSaveOptions) { const hasUnsavedChanges = ref(false) let timer: ReturnType | null = null + let workspaceGeneration = 0 /** * 触发防抖保存。 @@ -59,6 +60,7 @@ export function useAutoSave(options: AutoSaveOptions) { async function doSave(data: Record): Promise { 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, } } diff --git a/frontend/src/composables/usePosterWorkspace.ts b/frontend/src/composables/usePosterWorkspace.ts index fef127e..a6a0a5e 100644 --- a/frontend/src/composables/usePosterWorkspace.ts +++ b/frontend/src/composables/usePosterWorkspace.ts @@ -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 { 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 } diff --git a/frontend/src/composables/useWorkspace.ts b/frontend/src/composables/useWorkspace.ts index ff2c5b5..a3af55c 100644 --- a/frontend/src/composables/useWorkspace.ts +++ b/frontend/src/composables/useWorkspace.ts @@ -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) diff --git a/frontend/src/pages/PosterPage.vue b/frontend/src/pages/PosterPage.vue index 4666c8e..5825dac 100644 --- a/frontend/src/pages/PosterPage.vue +++ b/frontend/src/pages/PosterPage.vue @@ -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, diff --git a/frontend/src/pages/PptPage.vue b/frontend/src/pages/PptPage.vue index d72dc1b..3a65738 100644 --- a/frontend/src/pages/PptPage.vue +++ b/frontend/src/pages/PptPage.vue @@ -165,7 +165,7 @@