diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md b/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md index 160f17a..2e982c0 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md @@ -299,3 +299,12 @@ - 第二次服务器真实内核验收通过:铰链生成 113053 字节 STEP 和 61632 字节 GLB,孔工序数为 5;旋钮生成 30274 字节 STEP 和 21168 字节 GLB,孔工序数为 1。两个样例的 B-rep 都为零干涉,报告在服务器 `hardware-template-brep-v113-fused/report.json`。 - 本地结构图谱与制造层 91 项专项回归通过;最终完整回归 227 项全部通过(233.019秒,其中 1 项真实内核用例按设计仅在服务器开关启用时执行),Python 语法和 `git diff --check` 通过。 - 仍未完成公司实际五金 SKU 模板批准、原始多图到 v2 图谱的新识别、多产品冻结后 BOM/成本盲测和总成本误差不超过 5%。本版仍是未部署候选,不得用以上样例通过替代真实多产品验收。 + +## 2026-09-08 v115-v116 原图重跑与模型鉴权终止门(候选,未上线) + +- v115 启动前持续只读检查服务器真实任务状态;先等待正在执行的得物1688任务完成,确认 Chat API 活动、等待队列和持久任务均为 0 后,才在 `/tmp/dalongxia-cad-v85-eI8HeF` 使用四张原始产品图启动全新识别。没有复用公司 BOM、旧报告或历史模型结果,也没有修改生产目录、重启或部署服务。 +- v115 在进入 CAD 前被阿里云模型接口以 HTTP 401 `invalid_api_key` 拒绝。服务器密钥文件存在、权限为 600,且其内容指纹与用户最近提供的密钥完全一致;对同一接口的最小请求也稳定返回 401,证明这是远端鉴权凭据失效,不是任务缺图、缺尺寸或本地环境未加载密钥。 +- 旧异常分支把结构读图的 401 吞成普通机器重读失败,随后继续做尺寸完整性判断,最终误报为缺少 x/y/z 轴并可能引导用户补传素材。这条“鉴权失败后继续资格判断”的路线判定错误,已停止使用。 +- v116 将 401/403 单独归类为不可重试的 `model_credentials_invalid`。模型请求只执行一次,源资格层立即以 `external_dependency_failed` 终止,不再重复请求模型,也不再向用户虚构缺图、缺尺寸或 CAD 问题。服务器隔离现场耗时 3.753 秒,返回中文阻塞信息“读图模型已拒绝当前鉴权凭据”。 +- 候选完整回归 229 项通过(246.290 秒),1 项真实 CAD 内核用例按设计因本机未启用服务器依赖而跳过;新增专项测试验证 401 只请求一次、鉴权失败不进入读图/CAD循环。v115 因外部鉴权阻塞,尚未实际验证上一轮分阶段 CAD 修复门能否在这组原图上完成收敛,不能宣称原图重跑成功。 +- 当前仍需新的有效阿里云模型密钥才能继续多视图识别、CAD/BOM冻结盲测和公司成本误差不超过 5% 的目标。本轮候选未上线、未重启生产服务,接口状态必须保持测试失败/继续优化。 diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py b/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py index 76d73b5..5835ffc 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py @@ -50,6 +50,14 @@ def terminal_dependency_feedback(error: Exception | str) -> dict | None: "next_owner": "runtime_configuration", "retryable": False, } + if any(token in message for token in ("模型鉴权失败", "HTTP Error 401", "HTTP Error 403", "invalid_api_key")): + return { + "code": "model_credentials_invalid", + "severity": "blocking", + "detail": "读图模型已拒绝当前鉴权凭据", + "next_owner": "runtime_configuration", + "retryable": False, + } return None @@ -1197,16 +1205,20 @@ def main(): else: print(json.dumps({"case": case, "phase": "ai_source_evidence_qualification"}), flush=True) source_structure, source_dimensions = {}, {} + source_dependency = None try: source_structure = engine._read_source_structure(source_assets) except Exception as exc: source_structure = {"status": "machine_reread_failed", "error": f"{type(exc).__name__}: {exc}"} - try: - source_dimensions = engine._read_source_dimension_labels( - source_assets[-1], [source_assets[0], source_assets[1]] - ) - except Exception as exc: - source_dimensions = {"status": "machine_reread_failed", "error": f"{type(exc).__name__}: {exc}"} + source_dependency = terminal_dependency_feedback(exc) + if source_dependency is None: + try: + source_dimensions = engine._read_source_dimension_labels( + source_assets[-1], [source_assets[0], source_assets[1]] + ) + except Exception as exc: + source_dimensions = {"status": "machine_reread_failed", "error": f"{type(exc).__name__}: {exc}"} + source_dependency = terminal_dependency_feedback(exc) dimension_ledger = build_dimension_ledger( source_dimensions.get("overall_dimension_labels") or [], source_image_id="image4" ) @@ -1218,6 +1230,20 @@ def main(): "topology_contract": topology_contract, "policy": "AI先交叉识别和CAD复核;只有不可见且成本敏感的残余冲突才询问用户", } + if source_dependency: + report["status"] = "external_dependency_failed" + report["source_qualification"]["blocking"] = source_dependency + reports.append(report) + (case_dir / "report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + (out / "report.json").write_text( + json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(json.dumps({ + "case": case, "status": report["status"], "blocking": source_dependency, + }, ensure_ascii=False), flush=True) + continue source_qualification = report["source_qualification"] source_structure = source_qualification.get("structure") or {} source_dimensions = source_qualification.get("dimensions") or {} diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py b/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py new file mode 100644 index 0000000..decdaca --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py @@ -0,0 +1,16612 @@ +from __future__ import annotations + +import ast +import base64 +import hashlib +import importlib.util +import io +import json +import math +import os +import pathlib +import re +import sqlite3 +import statistics +import subprocess +import time +import urllib.error +import urllib.request +import uuid +import zipfile +from datetime import datetime, timezone +from typing import Callable + +from openpyxl import Workbook, load_workbook +from openpyxl.drawing.image import Image as XLImage +from openpyxl.styles import Alignment, Font, PatternFill +from PIL import Image as PILImage +from PIL import ImageDraw as PILImageDraw +from PIL import ImageFont as PILImageFont + + +ProgressCallback = Callable[[str, str, int, int], None] + + +class SolidWoodCostError(RuntimeError): + pass + + +class SolidWoodCostEngine: + """Stateful multi-view furniture CAD/BOM/cost estimator.""" + + DISPLAY_NAME = "产品成本评估" + MODULE_NAME = "家具成本评估" + COST_MODEL_VERSION = "2026-09-07-constraint-vision-annotation-v83" + DEFAULT_BATCH_QTY = 50 + PURE_SOLID_WOOD_FACTORY_PROFIT_RATE = 0.10 + NON_PURE_SOLID_WOOD_FACTORY_PROFIT_RATE = 0.08 + FACTORY_OUTPUT_TAX_RATE = 0.07 + WHOLESALE_1688_TAX_RATE = 0.05 + TERMINAL_STAGES = {"completed", "failed"} + PROGRESS_RE = re.compile(r"(?:继续|进度|查看进度|现在怎么样|完成了吗|结果呢)[??。\s]*$") + ENTRY_RE = re.compile( + r"^(?:产品成本评估|ai实木家具成本预估|实木家具成本预估|家具成本预估)?[\s,。!?!?、::]*$" + ) + ROLES = ("front", "side", "open_or_third", "dimension") + ROLE_LABELS = { + "front": "产品正视图", + "side": "产品侧视图", + "open_or_third": "开门图/第三视角图", + "dimension": "详细尺寸图", + } + FINISH_TEMPLATES = { + "无木器饰面": {"coverage_m2_l": 1.0, "coats": 0, "loss_rate": 0.0, "price_per_l": 0.0, "coating_labor_h_m2": 0.0, "intercoat_sanding_h_m2": 0.0}, + "木蜡油": {"coverage_m2_l": 18.0, "coats": 2, "loss_rate": 0.20, "price_per_l": 150.0, "coating_labor_h_m2": 0.57, "intercoat_sanding_h_m2": 0.08}, + "PU清漆": {"coverage_m2_l": 11.0, "coats": 3, "loss_rate": 0.40, "price_per_l": 95.0, "coating_labor_h_m2": 0.47, "intercoat_sanding_h_m2": 0.08}, + "开放漆": {"coverage_m2_l": 10.0, "coats": 4, "loss_rate": 0.40, "price_per_l": 110.0, "coating_labor_h_m2": 0.95, "intercoat_sanding_h_m2": 0.10}, + "封闭漆": {"coverage_m2_l": 8.0, "coats": 5, "loss_rate": 0.45, "price_per_l": 120.0, "coating_labor_h_m2": 1.25, "intercoat_sanding_h_m2": 0.10}, + "水性漆": {"coverage_m2_l": 10.0, "coats": 4, "loss_rate": 0.35, "price_per_l": 115.0, "coating_labor_h_m2": 0.85, "intercoat_sanding_h_m2": 0.10}, + # 默认按采购成品双饰面板报价。板价只补普通板到贴面板的公开价差, + # 不再把成品饰面板重复计成“裸板 + 双面贴面材料 + 油漆人工”。 + "三聚氰胺双饰面": { + "coverage_m2_l": 1.0, + "coats": 0, + "loss_rate": 0.0, + "price_per_l": 0.0, + "coating_labor_h_m2": 0.0, + "surface_labor_h_m2": 0.0, + "surface_material_m2": 0.0, + "prefinished_panel": True, + "panel_premium_m2": 0.60, + }, + "木皮+PU清漆": {"coverage_m2_l": 11.0, "coats": 3, "loss_rate": 0.40, "price_per_l": 95.0, "coating_labor_h_m2": 0.47, "intercoat_sanding_h_m2": 0.08, "surface_labor_h_m2": 0.16, "surface_material_m2": 38.0}, + "HPL耐磨饰面": {"coverage_m2_l": 1.0, "coats": 0, "loss_rate": 0.08, "price_per_l": 0.0, "coating_labor_h_m2": 0.0, "intercoat_sanding_h_m2": 0.0, "surface_labor_h_m2": 0.16, "surface_material_m2": 55.0}, + } + PANEL_MATERIALS = { + "MDF_UNGRADED": { + "name": "无等级密度板(MDF)", + "aliases": ("无等级密度板", "无环保等级密度板", "普通双贴密度板", "无等级mdf"), + }, + "MDF": {"name": "密度板(MDF)", "aliases": ("密度板", "中密度板", "中纤板", "mdf")}, + "PLYWOOD": {"name": "实木多层板", "aliases": ("实木多层板", "多层板", "胶合板", "plywood")}, + "PARTICLEBOARD": {"name": "刨花板", "aliases": ("刨花板", "颗粒板", "particleboard")}, + } + PANEL_GRADE_FACTORS = {"UNGRADED": 1.00, "E1": 1.00, "E0": 1.12, "ENF": 1.25} + METAL_MATERIALS = { + "Q235_BLACK": {"name": "Q235碳钢黑管", "aliases": ("q235", "碳钢", "黑管", "铁管")}, + "Q235_GALV": {"name": "Q235镀锌钢管", "aliases": ("镀锌", "镀锌钢")}, + "SS201": {"name": "201不锈钢", "aliases": ("201不锈钢", "不锈钢201")}, + "SS304": {"name": "304不锈钢", "aliases": ("304不锈钢", "不锈钢304")}, + "AL6063": {"name": "6063铝合金型材", "aliases": ("6063", "铝合金", "铝型材")}, + } + DEFAULT_METAL_MATERIAL_PRICES = ( + # code, name, density kg/m3, low/mid/high RMB/kg, purchase loss, source note + ( + "Q235_BLACK", "Q235碳钢黑管", 7850.0, 4.00, 4.45, 5.00, 0.06, + "国家统计局2026-08下旬Q235热轧卷3.3264元/kg、Q235中板3.5269元/kg;增加制管、规格与小批采购溢价后的家具型材估算种子", + ), + ( + "Q235_GALV", "Q235镀锌钢管", 7850.0, 4.30, 4.55, 4.80, 0.06, + "重庆2026-04造价信息镀锌钢管约4.32-4.74元/kg;正式报价应替换为供应商含税到厂价", + ), + ( + "SS201", "201不锈钢家具管", 7930.0, 8.80, 9.80, 11.20, 0.08, + "佛山2026-08市场201不锈钢卷约7.55-8.60元/kg;增加制管和小批规格溢价,不含后续拉丝或涂装", + ), + ( + "SS304", "304不锈钢家具管", 7930.0, 15.50, 16.80, 19.00, 0.08, + "佛山2026-08市场304不锈钢卷约13.90-14.40元/kg;增加制管和小批规格溢价,不含后续拉丝或涂装", + ), + ( + "AL6063", "6063铝合金型材", 2700.0, 24.00, 25.50, 27.00, 0.08, + "南海有色2026-08-21:6063铝棒22.51元/kg;增加通用挤压加工后作为未表面处理型材种子,不含喷涂或阳极氧化", + ), + ) + METAL_PROFILE_GROUPS = { + "square_tube": "方管", + "rect_tube": "矩形管", + "round_tube": "圆管", + "flat_bar": "扁钢/实心扁条", + "plate": "钢板/金属板", + } + DEFAULT_METAL_PROCESS_PRICES = ( + # code, name, unit, low/mid/high, setup/batch, allowed materials, source note + ("tube_cut", "型材锯切下料", "cut", 0.25, 0.45, 0.80, 8.0, "*", "家具管批量锯切估算种子;按实际切口数计费"), + ("laser_cut", "板材激光切割", "m", 3.00, 5.50, 9.00, 20.0, "*", "2026公开钣金报价3mm碳钢约5.5元/m;厚板按厚度系数修正"), + ("bend", "金属折弯", "bend", 1.50, 3.00, 5.00, 20.0, "*", "公开钣金小批量估算种子;按实际折弯道数计费"), + ("weld_co2", "CO2/MAG焊接", "m", 12.00, 18.00, 30.00, 10.0, "Q235_BLACK,Q235_GALV", "家具碳钢焊缝市场估算种子;按CAD焊缝长度计费"), + ("weld_tig", "TIG氩弧焊", "m", 20.00, 32.00, 50.00, 15.0, "SS201,SS304", "不锈钢家具焊缝市场估算种子;按CAD焊缝长度计费"), + ("weld_aluminum", "铝合金TIG焊", "m", 28.00, 45.00, 70.00, 18.0, "AL6063", "铝合金家具焊缝市场估算种子;按CAD焊缝长度计费"), + ("weld_grind", "焊缝打磨修整", "m", 4.00, 8.00, 12.00, 6.0, "*", "按需要外观修整的实际焊缝长度计费"), + ("metal_fastener", "金属拆装连接件", "joint", 0.80, 1.20, 2.00, 0.0, "*", "螺栓、螺母和连接片概算;按确认接头数计费"), + ("phosphating", "除油磷化前处理", "m2", 2.00, 2.50, 3.00, 12.0, "Q235_BLACK", "2026公开喷涂报价前处理加价约2-3元/㎡"), + ("powder_coat", "粉末静电喷涂", "m2", 12.00, 15.00, 22.00, 25.0, "Q235_GALV,SS201,SS304,AL6063", "2026公开喷涂市场简单件约13-15元/㎡、复杂件15-25元/㎡;不含不同材质专用前处理"), + ("powder_coat_company", "除油磷化+粉末静电喷涂(公司综合)", "m2", 6.30, 10.00, 15.00, 0.0, "Q235_BLACK", "公司BOM喷塑约4400条,近期综合单价常用约10元/㎡且未另列前处理;只用于Q235黑管,公开市场简单件约13-15元/㎡用于上限复核"), + ("wet_paint", "防锈底漆+面漆", "m2", 24.00, 29.00, 36.00, 25.0, "Q235_BLACK", "宁夏2026工程造价金属防锈一遍加醇酸漆两遍约29元/㎡"), + ("stainless_brush", "不锈钢拉丝", "m2", 12.00, 18.00, 25.00, 15.0, "SS201,SS304", "不锈钢家具表面拉丝市场估算种子"), + ("aluminum_anodize", "铝合金阳极氧化", "kg", 8.00, 12.00, 18.00, 30.0, "AL6063", "铝型材小批阳极氧化市场估算种子;按型材采购重量计费"), + ) + METAL_COMPONENT_TERMS = ( + "金属", "铁", "钢", "不锈钢", "铝", "方管", "圆管", "矩形管", "框架", "脚架", "桌架", "底座", "横撑", "拉杆", "支腿", + ) + METAL_COMPONENT_SYNONYMS = { + "支腿": ("支腿", "桌腿", "柜腿", "腿"), + "桌腿": ("桌腿", "支腿", "腿"), + "柜腿": ("柜腿", "支腿", "腿"), + "横撑": ("横撑", "横梁", "横杆", "拉杆", "支撑杆", "横向支撑杆"), + "拉杆": ("拉杆", "横撑", "横杆", "支撑杆", "横向支撑杆"), + "框架": ("框架", "脚架", "桌架", "金属架"), + "底座": ("底座", "底架", "基座"), + } + DEFAULT_PANEL_PRICES = ( + # code, name, thickness, low/sheet, mid/sheet, high/sheet, density, nesting yield, source note + # Company BOM calibration: the 90 cm sliding-door cabinet uses 12 mm + # double-faced MDF at 14.47811 RMB/m2. The price includes 0.60 RMB/m2 + # double-face premium, so the rows below store the inferred bare-board + # price. 15/18/20 mm are thickness-proportional estimates; the normal + # prefinished-panel premium is added exactly once by the quote engine. + ("MDF_UNGRADED", "无等级密度板(MDF)", 4.0, 12.59, 13.25, 13.91, 720.0, 0.89, "公司90cm推拉餐柜BOM校准:4mm双贴密度板5.05051元/㎡,扣除双贴价差后折算2440×1220裸板价"), + ("MDF_UNGRADED", "无等级密度板(MDF)", 12.0, 39.25, 41.31, 43.38, 720.0, 0.89, "公司90cm推拉餐柜BOM校准:12mm双贴密度板14.47811元/㎡,扣除0.60元/㎡双贴价差后折算2440×1220裸板价"), + ("MDF_UNGRADED", "无等级密度板(MDF)", 15.0, 49.48, 52.09, 54.69, 720.0, 0.89, "依据公司12mm无等级双贴密度板按厚度线性推算15mm;报价前可替换为供应商含税到厂价"), + ("MDF_UNGRADED", "无等级密度板(MDF)", 18.0, 59.72, 62.86, 66.00, 720.0, 0.89, "依据公司12mm无等级双贴密度板按厚度线性推算18mm;报价前可替换为供应商含税到厂价"), + ("MDF_UNGRADED", "无等级密度板(MDF)", 20.0, 66.54, 70.04, 73.55, 720.0, 0.89, "依据公司12mm无等级双贴密度板按厚度线性推算20mm;报价前可替换为供应商含税到厂价"), + ("MDF", "密度板(MDF)", 9.0, 46.8, 52.0, 59.8, 720.0, 0.86, "陕西2025-12造价信息:2440×1220×9mm中密度板52元/张"), + ("MDF", "密度板(MDF)", 12.0, 58.5, 65.0, 74.8, 720.0, 0.86, "陕西2025-12造价信息:2440×1220×12mm中密度板65元/张"), + ("MDF", "密度板(MDF)", 15.0, 66.6, 74.0, 85.1, 720.0, 0.86, "陕西2025-12造价信息:2440×1220×15mm中密度板74元/张"), + ("MDF", "密度板(MDF)", 18.0, 79.9, 88.8, 102.1, 720.0, 0.86, "按同源15mm板价线性折算;正式报价应替换为供应商18mm含税价"), + ("MDF", "密度板(MDF)", 25.0, 111.0, 123.3, 141.8, 720.0, 0.84, "按同源15mm板价线性折算;正式报价应替换为供应商25mm含税价"), + ("PLYWOOD", "实木多层板", 12.0, 117.4, 130.4, 150.0, 650.0, 0.84, "郑州2026年第1季度造价信息:2440×1200×12mm二级多层板43.80元/㎡折算"), + ("PLYWOOD", "实木多层板", 15.0, 135.0, 150.0, 172.5, 650.0, 0.84, "郑州2026年第1季度造价信息:2440×1220×15mm一级多层板50.40元/㎡折算"), + ("PLYWOOD", "实木多层板", 18.0, 153.7, 170.8, 196.4, 650.0, 0.84, "郑州2026年第1季度造价信息:2440×1220×18mm一级多层板57.36元/㎡折算"), + ("PLYWOOD", "实木多层板", 25.0, 213.5, 237.2, 272.8, 650.0, 0.82, "按同源18mm板价线性折算;正式报价应替换为供应商25mm含税价"), + ("PARTICLEBOARD", "刨花板", 12.0, 27.3, 30.4, 35.0, 660.0, 0.88, "郑州2026年第1季度造价信息:2440×1220×12mm刨花板10.20元/㎡折算"), + ("PARTICLEBOARD", "刨花板", 15.0, 35.4, 39.3, 45.2, 660.0, 0.88, "郑州2026年第1季度造价信息:2440×1220×15mm刨花板13.20元/㎡折算"), + ("PARTICLEBOARD", "刨花板", 18.0, 45.0, 50.0, 57.5, 660.0, 0.88, "郑州2026年第1季度造价信息:2440×1220×18mm刨花板16.80元/㎡折算"), + ("PARTICLEBOARD", "刨花板", 25.0, 62.5, 69.4, 79.8, 660.0, 0.86, "按同源18mm板价线性折算;正式报价应替换为供应商25mm含税价"), + # Internal auxiliary material used only for thin backs/drawer bottoms. It + # is intentionally not exposed as a primary panel-family choice. + ("BACKBOARD", "2.5mm薄型三合板/背板", 2.5, 14.0, 18.0, 24.0, 700.0, 0.94, "薄型背板工厂批量概算种子;不使用被测公司同款SKU或同款BOM价格"), + ) + HARDWARE_GRADE_FACTORS = {"basic": 1.0, "standard": 2.2, "premium": 5.0} + HARDWARE_GRADE_LABELS = {"basic": "基础量产五金", "standard": "标准缓冲五金", "premium": "品牌升级五金"} + HARDWARE_UNIT_PRICES = { + # 基础档按公司量产BOM校准;标准/升级档仍由上方系数换算。 + "hinge": 0.60, + "drawer_slide_pair": 2.50, + "handle": 0.28, + "connector": 0.06, + # 基础移门套件包含上下轨道及滚轮。按门洞计套,不按门片重复计整套。 + "sliding_track_set": 4.00, + # 生产连接件必须由CAD孔位方案反推,不能再按板件数给一个模糊预留。 + "confirmat_5x30": 0.05, + "confirmat_6_3x40": 0.06, + "confirmat_7x50": 0.08, + "confirmat_7x60": 0.10, + "wood_screw_4x40": 0.06, + "wood_dowel_8x30": 0.03, + "drawer_screw_3_5x30": 0.035, + "shelf_pin_5mm": 0.08, + "leg_hanger_bolt_m8": 0.60, + "threaded_insert_m8": 0.35, + } + CONNECTION_RULE_VERSION = "furniture-material-connection-2026-09-06-v1" + HARDWARE_TEMPLATE_VERSION = "furniture-drilling-template-2026-09-06-v3" + DEFAULT_MATERIAL_CONNECTION_RULES = ( + # material family, process code, allowed, release level, rule note + ("solid_wood", "traditional_mortise_tenon", 1, "conditional", "仅纯实木承力部件可在明确榫头、榫眼和纤维方向后使用传统榫卯"), + ("solid_wood", "wood_dowel", 1, "released", "实木可使用定位木销,需按厚度校验边距和孔深"), + ("solid_wood", "screw_connector", 1, "released", "实木螺钉连接需预钻孔并校验端距"), + ("engineered_panel", "traditional_mortise_tenon", 0, "blocked", "密度板、刨花板、实木多层板不得生成传统承力榫卯工艺及费用"), + ("engineered_panel", "confirmat", 1, "released", "板式主体按已确认板厚选用确认螺钉并生成配对孔"), + ("engineered_panel", "wood_dowel", 1, "released", "板式家具只允许用作定位木销,不得标记或计价为传统榫卯"), + ("engineered_panel", "cam_connector", 1, "released", "三合一拆装件必须绑定确定的排钻模板"), + ("metal", "traditional_mortise_tenon", 0, "blocked", "金属件不生成木工榫卯"), + ("metal", "weld", 1, "conditional", "焊接必须有接头类型、焊缝符号、焊脚/焊透尺寸与焊缝长度"), + ("metal", "bolt_rivet", 1, "released", "金属拆装件采用螺栓、螺母、铆钉或铆螺母,需绑定孔位模板"), + ("mixed", "traditional_mortise_tenon", 0, "blocked", "混合材料界面不允许默认生成传统榫卯"), + ("mixed", "insert_bracket_through_bolt", 1, "released", "木金属界面优先使用预埋螺母、连接片或穿透螺栓"), + ) + DEFAULT_HARDWARE_DRILLING_TEMPLATES = ( + # template id, hardware code, display name, material families, schema json, source + ("DLX-CONFIRMAT-5X30-V1", "confirmat_5x30", "5×30mm确认螺钉预钻模板", "engineered_panel", {"pilot_diameter_mm": 4.0, "pilot_depth_mm": 25.0, "minimum_panel_thickness_mm": 10.0}, "公司量产板式连接规则"), + ("DLX-CONFIRMAT-6.3X40-V1", "confirmat_6_3x40", "6.3×40mm确认螺钉预钻模板", "engineered_panel", {"pilot_diameter_mm": 5.0, "pilot_depth_mm": 32.0, "minimum_panel_thickness_mm": 13.0}, "公司量产板式连接规则"), + ("DLX-CONFIRMAT-7X50-V1", "confirmat_7x50", "7×50mm确认螺钉预钻模板", "engineered_panel", {"pilot_diameter_mm": 5.0, "pilot_depth_mm": 40.0, "minimum_panel_thickness_mm": 16.0}, "公司量产板式连接规则"), + ("DLX-CONFIRMAT-7X60-V1", "confirmat_7x60", "7×60mm确认螺钉预钻模板", "engineered_panel", {"pilot_diameter_mm": 5.0, "pilot_depth_mm": 48.0, "minimum_panel_thickness_mm": 19.0}, "公司量产板式连接规则"), + ("DLX-DOWEL-8X30-V1", "wood_dowel_8x30", "Φ8×30mm定位木销模板", "solid_wood,engineered_panel", {"diameter_mm": 8.0, "length_mm": 30.0, "minimum_edge_distance_mm": 16.0}, "定位木销公司通用模板,非传统榫卯"), + ("DLX-WOOD-SCREW-4X40-V1", "wood_screw_4x40", "4×40mm木螺钉预钻模板", "solid_wood", {"pilot_diameter_mm": 3.0, "pilot_depth_mm": 32.0, "minimum_edge_distance_mm": 12.0}, "公司实木连接规则"), + ("DLX-DRAWER-SCREW-3.5X30-V1", "drawer_screw_3_5x30", "3.5×30mm抽屉螺钉预钻模板", "solid_wood,engineered_panel", {"pilot_diameter_mm": 2.5, "pilot_depth_mm": 28.0, "minimum_edge_distance_mm": 8.0}, "公司量产抽屉箱连接规则"), + ("DLX-SHELF-PIN-5MM-V1", "shelf_pin_5mm", "5mm层板托定位孔模板", "solid_wood,engineered_panel", {"diameter_mm": 5.0, "pilot_depth_mm": 10.0, "minimum_edge_distance_mm": 12.0}, "公司量产可调层板托通用模板"), + ("DLX-LEG-HANGER-M8-V1", "leg_hanger_bolt_m8", "M8柜腿吊脚螺栓预钻模板", "solid_wood,engineered_panel", {"pilot_diameter_mm": 6.0, "pilot_depth_mm": 25.0, "minimum_edge_distance_mm": 12.0}, "柜腿量产可拆连接通用模板,正式下料前仍需绑定供应商SKU"), + ("DLX-INSERT-M8-V1", "threaded_insert_m8", "M8预埋螺母底孔模板", "solid_wood,engineered_panel", {"pilot_diameter_mm": 10.0, "pilot_depth_mm": 15.0, "minimum_edge_distance_mm": 12.0}, "柜底量产预埋螺母通用模板,正式下料前仍需绑定供应商SKU"), + ) + DEFAULT_EDGE_PRICES = ( + ("PVC_0_5", "0.5mm PVC封边", "m", 0.22, 0.30, 0.45, 0.08), + ("PVC_1", "1mm PVC封边", "m", 0.32, 0.45, 0.65, 0.08), + ("PVC_2", "2mm PVC封边", "m", 0.65, 0.90, 1.20, 0.08), + ("ABS_0_5", "0.5mm ABS封边", "m", 0.40, 0.52, 0.70, 0.08), + ("ABS_1", "1mm ABS封边", "m", 0.55, 0.70, 0.95, 0.08), + ("ABS_2", "2mm ABS封边", "m", 0.90, 1.20, 1.60, 0.08), + ("SOLID_WOOD", "实木封边", "m", 3.00, 5.00, 8.00, 0.12), + ) + SHIPPING_SCENARIOS = { + "国内专线": {"buffer_mm": 30.0, "epe_factor": 0.65, "honeycomb_factor": 0.0, "desiccant_each": 1}, + # 国内电商板式家具不再无条件叠加蜂窝板;跨境或用户明确选择高防护时才使用。 + "电商快递": {"buffer_mm": 50.0, "epe_factor": 1.00, "honeycomb_factor": 0.0, "desiccant_each": 1}, + "跨境运输": {"buffer_mm": 65.0, "epe_factor": 1.20, "honeycomb_factor": 0.70, "desiccant_each": 4}, + } + PACKAGING_BOX_STYLES = { + "rsc_center_open": { + "label": "中开箱", + "fefco_family": "FEFCO 0201类", + "description": "一片成型、上下摇盖在中线封合,适合常规拆装平板家具量产包装", + "labor_delta_minutes": 0.0, + }, + "telescopic_lid_base": { + "label": "天地盖", + "fefco_family": "FEFCO 03类", + "description": "上盖与下托分体,装入和开箱更直观,适合整装、较重或需要加强边角保护的产品", + "labor_delta_minutes": 2.0, + }, + "sleeve_lid_base": { + "label": "围板天地盖", + "fefco_family": "FEFCO 03/09组合类", + "description": "底托、围板与上盖分体,适合超长、超重或跨境运输的家具", + "labor_delta_minutes": 4.0, + }, + } + CARTON_GRADES = { + "company_basic": { + "label": "简装级纸箱", + "material_code": "carton_basic", + "cost_multiplier": 0.75, + "description": "公司历史BOM的低价档,限轻小件、拆装和国内简装场景", + "source_note": "公司2026历史BOM存在¥3.00/㎡纸箱采购记录;仅作为低价简装成本档", + "specification_status": "supplier_confirmation_required", + }, + "company_economy": { + "label": "经济级纸箱", + "material_code": "carton_economy", + "cost_multiplier": 1.0, + "description": "公司90cm推拉餐柜BOM的批量采购成本基准,适合常规拆装板式家具", + "source_note": "公司90cm推拉餐柜BOM:纸箱计价面积4.088㎡×¥4.00/㎡=¥16.352", + "specification_status": "supplier_confirmation_required", + }, + "company_standard": { + "label": "标准级纸箱", + "material_code": "carton_standard", + "cost_multiplier": 1.5, + "description": "公司内部标准成本口径,适合整装或保护要求较高的国内运输", + "source_note": "公司纸箱内部成本标准:按90cm经济级基准的1.50倍,¥6.00/㎡", + "specification_status": "supplier_confirmation_required", + }, + "company_reinforced": { + "label": "加强级纸箱", + "material_code": "carton_reinforced", + "cost_multiplier": 2.0, + "description": "公司内部加强成本口径,适合跨境、超长、较重或高防护场景", + "source_note": "公司纸箱内部成本标准:按90cm经济级基准的2.00倍,¥8.00/㎡", + "specification_status": "supplier_confirmation_required", + }, + } + COMPONENT_COLORS = ( + "#2F80ED", "#27AE60", "#F2994A", "#9B51E0", "#EB5757", + "#56CCF2", "#6FCF97", "#F2C94C", "#BB6BD9", "#2D9CDB", + "#219653", "#D35400", "#8E44AD", "#C0392B", "#16A085", + ) + DEFAULT_LABOR_RATES = ( + ("cutting", "开料工", 46.0), + ("machining", "机加工", 52.0), + ("assembly", "木工组装", 50.0), + ("sanding", "打磨工", 42.0), + ("finishing", "油漆工", 50.0), + ("packaging", "包装工", 38.0), + ("quality", "质检工", 45.0), + ) + DEFAULT_PROCESS_STANDARDS = ( + ("material_cutting", "选料与开料", "cutting", 20.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ("thicknessing", "刨料与定厚", "machining", 15.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ("cnc_joinery", "打孔开槽与连接加工", "machining", 25.0, 1.5, 0.0, 0.0, 0.7, 2.0, 2.5, 1.2), + ("assembly", "试装与组装", "assembly", 10.0, 2.0, 0.0, 12.0, 0.0, 0.0, 2.0, 0.0), + ("sanding", "白坯打磨", "sanding", 10.0, 0.0, 18.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ("intercoat_sanding", "层间打磨", "sanding", 8.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ("surface_press", "贴面压贴与修边", "machining", 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ("carving", "雕刻与造型", "machining", 15.0, 0.0, 45.0, 0.0, 0.0, 0.0, 0.0, 1.2), + ("finishing", "油漆施工", "finishing", 20.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ("packaging", "成品包装", "packaging", 10.0, 18.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ("quality", "尺寸与外观质检", "quality", 5.0, 0.5, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0), + ("panel_edge_banding", "板件封边", "machining", 12.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.08), + ("hardware_installation", "五金定位、安装与配件包", "assembly", 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ) + LABOR_CALIBRATION_VERSION = "china-furniture-effective-hour-2026-09-v3" + PROCESS_CALIBRATION_VERSION = "production-drawing-material-rules-2026-09-06-v7" + OVERHEAD_RESOURCE_VERSION = "factory-resource-pools-2026-09-v1" + MACHINE_HOURLY_COSTS = { + "material_cutting": 8.0, + "thicknessing": 10.0, + "cnc_joinery": 12.0, + "panel_edge_banding": 9.0, + "hardware_installation": 1.0, + "surface_press": 7.0, + "carving": 14.0, + "sanding": 3.0, + "intercoat_sanding": 3.0, + "finishing": 5.0, + "assembly": 1.0, + "quality": 0.5, + "packaging": 0.8, + } + FACTORY_OCCUPANCY_PER_HOUR = 2.50 + WORKSHOP_MANAGEMENT_LABOR_RATE = 0.05 + DEFAULT_PACKAGING_PRICES = ( + ("carton_basic", "简装级瓦楞纸箱(公司历史低价档)", "㎡", 3.00, 0.02), + ("carton_economy", "经济级瓦楞纸箱(公司90cm BOM基准)", "㎡", 4.00, 0.02), + ("carton_standard", "标准级瓦楞纸箱", "㎡", 6.00, 0.02), + ("carton_reinforced", "加强级瓦楞纸箱", "㎡", 8.00, 0.02), + # 保留旧代码仅用于读取历史报价;新价格一律使用上述明确等级。 + ("carton_5ply", "历史五层瓦楞纸箱(仅兼容)", "㎡", 3.50, 0.02), + ("epe_roll", "EPE珍珠棉卷材", "㎡", 3.20, 0.02), + ("epe_sheet", "EPE珍珠棉厚防护板", "㎡", 18.00, 0.02), + ("corner_guard", "纸护角", "个", 0.30, 0.02), + ("pe_bag", "PE防尘袋", "个", 3.50, 0.02), + ("tape", "封箱胶带", "m", 0.12, 0.02), + ("strap", "打包带", "m", 0.18, 0.02), + ("desiccant", "干燥剂", "包", 0.35, 0.02), + ("hardware_bag", "五金配件包", "套", 0.80, 0.02), + ("label_manual", "标签与说明书", "套", 0.50, 0.02), + ("honeycomb", "蜂窝板", "㎡", 7.00, 0.02), + ("pallet", "托盘", "个", 75.00, 0.02), + ) + PACKAGING_CALIBRATION_VERSION = "company-carton-grades-2026-09-06-v4" + METAL_PRICE_VERSION = "china-furniture-metal-public-2026-09-04-v2" + METAL_PROCESS_VERSION = "china-furniture-metal-company-calibrated-2026-09-04-v2" + COMPANY_METAL_BENCHMARK_VERSION = "company-bom-metal-2026-09-04-v1" + SPECIAL_OPERATION_PRICE_VERSION = "company-1688-special-operations-2026-09-04-v1" + DEFAULT_SPECIAL_OPERATION_PRICES = ( + # code, name, driver unit, price excluding supplied materials, price including materials + ("woven_bag_rope_remove_barcode", "套绳编织袋(含撕掉条码)", "piece", 2.00, 6.00), + ("woven_bag", "套编织袋", "piece", 1.50, 5.00), + ("film_wrap", "覆膜", "piece", 2.50, 5.00), + ("shipping_mark", "贴唛头", "piece", 0.15, 0.20), + ("corner_protection", "打护角", "piece", 1.50, 3.00), + ("container_loading_unloading", "装柜、卸车", "m3", 8.00, 8.00), + ("insert_manual", "放说明书", "piece", 0.50, 1.50), + ("replace_manual", "更换说明书", "piece", 1.00, 2.00), + ("cover_factory_address", "牛皮纸覆盖厂名厂址", "piece", 1.00, 1.50), + ("remove_certificate", "去除合格证", "piece", 1.50, 1.50), + ) + DEFAULT_COMPANY_METAL_PROFILE_BENCHMARKS = ( + # material, shape, width, height, diameter, wall, low/m, mid/m, high/m, + # company loss, rows, SKUs, latest, confidence, note + ("Q235_BLACK", "square_tube", 15.0, 15.0, 0.0, 0.5, 1.00, 1.40, 1.50, 0.01, 5624, 1225, "2026-07-18", "high", "公司钢材BOM:area为总延米,amount=area×unitPrice×coefficient;近一年加权中位约1.40元/米"), + ("Q235_BLACK", "square_tube", 15.0, 15.0, 0.0, 0.6, 1.10, 1.50, 1.60, 0.01, 2423, 667, "2026-06-01", "high", "公司钢材BOM同规格近期主价1.50元/米"), + ("Q235_BLACK", "square_tube", 20.0, 20.0, 0.0, 0.5, 1.29, 1.32, 1.50, 0.01, 1174, 303, "2026-09-01", "high", "公司钢材BOM同规格近一年加权中位约1.32元/米"), + ("Q235_BLACK", "square_tube", 20.0, 20.0, 0.0, 0.6, 1.50, 1.50, 1.68, 0.01, 2004, 468, "2026-06-23", "high", "公司钢材BOM同规格近期主价1.50元/米"), + ("Q235_BLACK", "rect_tube", 30.0, 15.0, 0.0, 0.6, 1.69, 1.73, 1.80, 0.01, 1335, 392, "2025-12-24", "high", "公司钢材BOM同规格近期主价1.69-1.73元/米"), + ("Q235_BLACK", "rect_tube", 40.0, 20.0, 0.0, 0.6, 2.30, 2.33, 2.80, 0.01, 462, 143, "2026-06-23", "high", "公司钢材BOM同规格近期主价2.30-2.33元/米"), + ("Q235_BLACK", "square_tube", 40.0, 40.0, 0.0, 0.6, 3.10, 3.30, 3.34, 0.01, 525, 229, "2026-06-23", "high", "公司钢材BOM同规格近一年加权中位约3.30元/米"), + ("Q235_BLACK", "round_tube", 0.0, 0.0, 16.0, 0.9, 0.80, 0.84, 0.90, 0.01, 32, 8, "2022-11-05", "low", "公司历史样本较旧且SKU仅8个,只用于偏差提示,不自动采用"), + ) + HISTORICAL_PRODUCT_KEYWORDS = ( + "餐边柜", "电视柜", "床头柜", "鞋柜", "斗柜", "酒柜", "书柜", "衣柜", "文件柜", "边柜", "储物柜", "柜子", + "餐桌", "书桌", "电脑桌", "梳妆台", "茶几", "边几", "床", "沙发", "椅子", "凳子", "层架", "置物架", + ) + HISTORICAL_MATERIAL_TERMS = { + "MDF": ("密度板", "中纤板", "中密度板"), + "PLYWOOD": ("多层板", "胶合板", "生态板", "三合板"), + "PARTICLEBOARD": ("刨花板", "颗粒板"), + "SOLID_WOOD": ("实木", "橡木", "橡胶木", "松木", "胡桃木", "白蜡木", "桐木"), + } + HISTORICAL_SOLID_SPECIES = ( + (("白橡",), ("北美白橡", "白橡木", "白橡")), + (("红橡",), ("红橡木", "红橡")), + (("橡胶木",), ("橡胶木",)), + (("黑胡桃",), ("北美黑胡桃", "黑胡桃木", "黑胡桃")), + (("白蜡",), ("白蜡木", "白蜡")), + (("桐木",), ("桐木",)), + (("松木", "樟子松"), ("樟子松", "松木")), + ) + + def __init__(self) -> None: + self.state_dir = pathlib.Path( + os.environ.get( + "DALONGXIA_SOLID_WOOD_COST_STATE_DIR", + "/var/lib/dalongxia-chat/ai-solid-wood-cost", + ) + ) + self.price_xlsx = pathlib.Path( + os.environ.get( + "DALONGXIA_SOLID_WOOD_PRICE_XLSX", + "/opt/dalongxia-chat-api/ai-solid-wood-cost/实木家具材种价格表2026年8月7日版本.xlsx", + ) + ) + self.ai3d_path = pathlib.Path( + os.environ.get( + "DALONGXIA_AI_3D_ENGINE_PATH", + "/opt/dalongxia-chat-api/ai-3d-model/ai_3d_model_engine.py", + ) + ) + self.model = os.environ.get("DALONGXIA_SOLID_WOOD_VISION_MODEL", "qwen-vl-plus") + self.api_base = os.environ.get( + "DALONGXIA_AI_3D_API_BASE", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + ).rstrip("/") + self.timeout = max(60, int(os.environ.get("DALONGXIA_SOLID_WOOD_TIMEOUT", "360"))) + self.cad_review_timeout = max( + 60, + min(self.timeout, int(os.environ.get("DALONGXIA_SOLID_WOOD_CAD_REVIEW_TIMEOUT", "180"))), + ) + self.labor_rate = max(1.0, float(os.environ.get("DALONGXIA_SOLID_WOOD_LABOR_RATE", "45"))) + # Active labor rates are effective-production-hour rates: employer burden and + # normal efficiency are already included. They must not be divided by an + # efficiency factor again. The nominal value is kept only for audit display. + self.production_efficiency = min(1.0, max(0.5, float(os.environ.get("DALONGXIA_SOLID_WOOD_PRODUCTION_EFFICIENCY", "0.85")))) + self.history_enabled = str(os.environ.get("DALONGXIA_SOLID_WOOD_HISTORY_BOM_ENABLED", "1")).strip().lower() not in {"0", "false", "no", "off"} + self.history_query_command = pathlib.Path( + os.environ.get( + "DALONGXIA_COMPANY_QUERY_COMMAND", + "/opt/dalongxia-runtime/chat-query-client/dalongxia-query", + ) + ) + self.history_query_socket = pathlib.Path( + os.environ.get( + "DALONGXIA_COMPANY_QUERY_SOCKET", + "/var/lib/dalongxia-chat-query/workspaces/company/.dalongxia/query.sock", + ) + ) + self.history_query_timeout = max(3, min(30, int(os.environ.get("DALONGXIA_SOLID_WOOD_HISTORY_BOM_TIMEOUT", "12")))) + self.state_dir.mkdir(parents=True, exist_ok=True) + (self.state_dir / "jobs").mkdir(parents=True, exist_ok=True) + self.db_path = self.state_dir / "solid-wood-cost.sqlite3" + self._ai3d = None + self._init_db() + self._ensure_price_data() + self._ensure_costing_standards() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + def _init_db(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS wood_price_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL, + file_sha256 TEXT NOT NULL UNIQUE, + source_filename TEXT NOT NULL, + source_path TEXT NOT NULL, + imported_at TEXT NOT NULL, + record_count INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS wood_species_prices ( + price_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES wood_price_versions(version_id), + source_row INTEGER NOT NULL, + species_name TEXT NOT NULL, + grade TEXT NOT NULL, + density_min REAL, + density_max REAL, + density_mid REAL, + price_basis TEXT NOT NULL, + cubic_low REAL, + cubic_high REAL, + cubic_mid REAL NOT NULL, + ton_low REAL, + ton_high REAL, + ton_mid REAL, + yield_rate REAL NOT NULL, + usage_note TEXT NOT NULL, + source_json TEXT NOT NULL, + UNIQUE(version_id, species_name) + ); + CREATE INDEX IF NOT EXISTS idx_wood_prices_active_name + ON wood_species_prices(version_id, species_name); + CREATE TABLE IF NOT EXISTS projects ( + project_id TEXT PRIMARY KEY, + context_key TEXT NOT NULL, + stage TEXT NOT NULL, + display_text TEXT NOT NULL, + product_name TEXT NOT NULL DEFAULT '', + analysis_json TEXT NOT NULL DEFAULT '{}', + selections_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_solid_wood_context_updated + ON projects(context_key, updated_at DESC); + CREATE TABLE IF NOT EXISTS project_assets ( + asset_id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(project_id), + role TEXT NOT NULL, + filename TEXT NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + sha256 TEXT NOT NULL, + validation_status TEXT NOT NULL DEFAULT 'pending', + validation_json TEXT NOT NULL DEFAULT '{}', + replaced_by_asset_id INTEGER, + created_at TEXT NOT NULL, + UNIQUE(project_id, sha256) + ); + CREATE TABLE IF NOT EXISTS bom_components ( + component_id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(project_id), + part_id TEXT NOT NULL, + part_name TEXT NOT NULL, + quantity INTEGER NOT NULL, + dimensions_json TEXT NOT NULL, + origins_json TEXT NOT NULL, + material_group TEXT NOT NULL, + selected_species TEXT NOT NULL, + net_volume_m3 REAL NOT NULL, + coating_area_m2 REAL NOT NULL, + confidence REAL NOT NULL, + UNIQUE(project_id, part_id) + ); + CREATE TABLE IF NOT EXISTS quote_versions ( + quote_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(project_id), + price_version_id INTEGER NOT NULL REFERENCES wood_price_versions(version_id), + quote_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS labor_rate_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS labor_rates ( + rate_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES labor_rate_versions(version_id), + worker_code TEXT NOT NULL, + worker_name TEXT NOT NULL, + hourly_rate REAL NOT NULL, + UNIQUE(version_id, worker_code) + ); + CREATE TABLE IF NOT EXISTS process_standard_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS process_time_standards ( + standard_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES process_standard_versions(version_id), + process_code TEXT NOT NULL, + process_name TEXT NOT NULL, + worker_code TEXT NOT NULL, + setup_minutes REAL NOT NULL, + unit_minutes REAL NOT NULL, + area_minutes_m2 REAL NOT NULL, + door_minutes REAL NOT NULL, + hole_minutes REAL NOT NULL, + slot_minutes REAL NOT NULL, + joint_minutes REAL NOT NULL, + profile_minutes_m REAL NOT NULL, + UNIQUE(version_id, process_code) + ); + CREATE TABLE IF NOT EXISTS packaging_price_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS packaging_material_prices ( + price_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES packaging_price_versions(version_id), + material_code TEXT NOT NULL, + material_name TEXT NOT NULL, + unit TEXT NOT NULL, + unit_price REAL NOT NULL, + waste_rate REAL NOT NULL, + UNIQUE(version_id, material_code) + ); + CREATE TABLE IF NOT EXISTS panel_price_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + source_urls_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS engineered_panel_prices ( + price_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES panel_price_versions(version_id), + material_code TEXT NOT NULL, + material_name TEXT NOT NULL, + standard_length_mm REAL NOT NULL DEFAULT 2440, + standard_width_mm REAL NOT NULL DEFAULT 1220, + thickness_mm REAL NOT NULL, + price_low_per_sheet REAL NOT NULL, + price_mid_per_sheet REAL NOT NULL, + price_high_per_sheet REAL NOT NULL, + density_kg_m3 REAL NOT NULL, + nesting_yield_rate REAL NOT NULL, + source_note TEXT NOT NULL, + UNIQUE(version_id, material_code, thickness_mm) + ); + CREATE TABLE IF NOT EXISTS edge_price_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS edge_material_prices ( + price_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES edge_price_versions(version_id), + edge_code TEXT NOT NULL, + edge_name TEXT NOT NULL, + unit TEXT NOT NULL, + price_low REAL NOT NULL, + price_mid REAL NOT NULL, + price_high REAL NOT NULL, + waste_rate REAL NOT NULL, + UNIQUE(version_id, edge_code) + ); + CREATE TABLE IF NOT EXISTS metal_price_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + source_urls_json TEXT NOT NULL DEFAULT '[]', + benchmark_date TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS metal_material_prices ( + price_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES metal_price_versions(version_id), + material_code TEXT NOT NULL, + material_name TEXT NOT NULL, + density_kg_m3 REAL NOT NULL, + price_low_kg REAL NOT NULL, + price_mid_kg REAL NOT NULL, + price_high_kg REAL NOT NULL, + waste_rate REAL NOT NULL, + source_note TEXT NOT NULL, + UNIQUE(version_id, material_code) + ); + CREATE TABLE IF NOT EXISTS metal_profile_specs ( + spec_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES metal_price_versions(version_id), + profile_code TEXT NOT NULL, + profile_shape TEXT NOT NULL, + display_name TEXT NOT NULL, + width_mm REAL NOT NULL DEFAULT 0, + height_mm REAL NOT NULL DEFAULT 0, + diameter_mm REAL NOT NULL DEFAULT 0, + wall_thickness_mm REAL NOT NULL, + theoretical_kg_m REAL NOT NULL, + recommended_use TEXT NOT NULL, + standard_ref TEXT NOT NULL, + UNIQUE(version_id, profile_code) + ); + CREATE TABLE IF NOT EXISTS metal_process_price_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + source_urls_json TEXT NOT NULL DEFAULT '[]', + benchmark_date TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS metal_process_prices ( + price_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES metal_process_price_versions(version_id), + process_code TEXT NOT NULL, + process_name TEXT NOT NULL, + driver_unit TEXT NOT NULL, + price_low REAL NOT NULL, + price_mid REAL NOT NULL, + price_high REAL NOT NULL, + setup_cost_batch REAL NOT NULL, + allowed_materials TEXT NOT NULL, + source_note TEXT NOT NULL, + UNIQUE(version_id, process_code) + ); + CREATE TABLE IF NOT EXISTS metal_company_benchmark_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_table TEXT NOT NULL, + benchmark_date TEXT NOT NULL, + source_note TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS metal_company_profile_benchmarks ( + benchmark_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES metal_company_benchmark_versions(version_id), + material_code TEXT NOT NULL, + profile_shape TEXT NOT NULL, + width_mm REAL NOT NULL DEFAULT 0, + height_mm REAL NOT NULL DEFAULT 0, + diameter_mm REAL NOT NULL DEFAULT 0, + wall_thickness_mm REAL NOT NULL, + price_low_m REAL NOT NULL, + price_mid_m REAL NOT NULL, + price_high_m REAL NOT NULL, + waste_rate REAL NOT NULL, + sample_count INTEGER NOT NULL, + sku_count INTEGER NOT NULL, + latest_at TEXT NOT NULL, + confidence TEXT NOT NULL, + source_note TEXT NOT NULL, + UNIQUE(version_id, material_code, profile_shape, width_mm, height_mm, diameter_mm, wall_thickness_mm) + ); + CREATE TABLE IF NOT EXISTS historical_bom_cache ( + cache_key TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at_epoch REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS special_operation_price_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS special_operation_prices ( + price_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES special_operation_price_versions(version_id), + operation_code TEXT NOT NULL, + operation_name TEXT NOT NULL, + driver_unit TEXT NOT NULL, + price_excluding_material REAL NOT NULL, + price_including_material REAL NOT NULL, + UNIQUE(version_id, operation_code) + ); + CREATE TABLE IF NOT EXISTS cost_quote_archives ( + archive_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE REFERENCES projects(project_id), + conversation_id TEXT NOT NULL DEFAULT '', + requested_by_user_id TEXT NOT NULL DEFAULT '', + operator_id TEXT NOT NULL DEFAULT '', + operator_context_json TEXT NOT NULL DEFAULT '{}', + product_name TEXT NOT NULL DEFAULT '', + cost_model_version TEXT NOT NULL, + base_cost REAL NOT NULL, + factory_profit_rate REAL NOT NULL DEFAULT 0, + factory_profit_amount REAL NOT NULL DEFAULT 0, + factory_output_tax_rate REAL NOT NULL DEFAULT 0, + factory_output_tax_amount REAL NOT NULL DEFAULT 0, + bom_factory_quote REAL NOT NULL DEFAULT 0, + special_operation_total REAL NOT NULL DEFAULT 0, + invoice_tax_rate REAL NOT NULL DEFAULT 0, + invoice_tax_amount REAL NOT NULL DEFAULT 0, + profit_margin REAL NOT NULL DEFAULT 0, + final_quote REAL NOT NULL, + selections_json TEXT NOT NULL, + quote_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS cost_quote_archive_assets ( + archive_asset_id INTEGER PRIMARY KEY AUTOINCREMENT, + archive_id TEXT NOT NULL REFERENCES cost_quote_archives(archive_id) ON DELETE CASCADE, + source_asset_id INTEGER, + role TEXT NOT NULL, + filename TEXT NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + sha256 TEXT NOT NULL, + validation_status TEXT NOT NULL, + validation_json TEXT NOT NULL, + UNIQUE(archive_id, source_asset_id) + ); + CREATE TABLE IF NOT EXISTS cost_quote_archive_bom_lines ( + archive_bom_id INTEGER PRIMARY KEY AUTOINCREMENT, + archive_id TEXT NOT NULL REFERENCES cost_quote_archives(archive_id) ON DELETE CASCADE, + part_id TEXT NOT NULL, + part_name TEXT NOT NULL, + quantity REAL NOT NULL, + dimensions_json TEXT NOT NULL, + material_name TEXT NOT NULL, + cost_mid REAL NOT NULL, + line_json TEXT NOT NULL, + UNIQUE(archive_id, part_id) + ); + CREATE TABLE IF NOT EXISTS cost_quote_archive_special_operations ( + archive_operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + archive_id TEXT NOT NULL REFERENCES cost_quote_archives(archive_id) ON DELETE CASCADE, + operation_code TEXT NOT NULL, + operation_name TEXT NOT NULL, + material_basis TEXT NOT NULL, + driver_quantity REAL NOT NULL, + driver_unit TEXT NOT NULL, + unit_price REAL NOT NULL, + amount REAL NOT NULL, + UNIQUE(archive_id, operation_code) + ); + CREATE TABLE IF NOT EXISTS connection_rule_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS material_connection_rules ( + rule_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL REFERENCES connection_rule_versions(version_id), + material_family TEXT NOT NULL, + process_code TEXT NOT NULL, + is_allowed INTEGER NOT NULL, + release_level TEXT NOT NULL, + rule_note TEXT NOT NULL, + UNIQUE(version_id, material_family, process_code) + ); + CREATE TABLE IF NOT EXISTS hardware_drilling_template_versions ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + version_label TEXT NOT NULL UNIQUE, + source_note TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS hardware_drilling_templates ( + template_id TEXT NOT NULL, + version_id INTEGER NOT NULL REFERENCES hardware_drilling_template_versions(version_id), + hardware_code TEXT NOT NULL, + display_name TEXT NOT NULL, + allowed_material_families TEXT NOT NULL, + parameters_json TEXT NOT NULL, + source_note TEXT NOT NULL, + PRIMARY KEY(version_id, template_id), + UNIQUE(version_id, hardware_code) + ); + CREATE INDEX IF NOT EXISTS idx_cost_quote_archives_user_created + ON cost_quote_archives(requested_by_user_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_cost_quote_archives_operator_created + ON cost_quote_archives(operator_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_cost_quote_archives_product_created + ON cost_quote_archives(product_name, created_at DESC); + """ + ) + asset_columns = { + str(row[1]) for row in conn.execute("PRAGMA table_info(project_assets)").fetchall() + } + if "validation_status" not in asset_columns: + conn.execute( + "ALTER TABLE project_assets ADD COLUMN validation_status TEXT NOT NULL DEFAULT 'pending'" + ) + if "validation_json" not in asset_columns: + conn.execute( + "ALTER TABLE project_assets ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'" + ) + if "replaced_by_asset_id" not in asset_columns: + conn.execute("ALTER TABLE project_assets ADD COLUMN replaced_by_asset_id INTEGER") + bom_columns = { + str(row[1]) for row in conn.execute("PRAGMA table_info(bom_components)").fetchall() + } + bom_additions = { + "selected_material_family": "TEXT NOT NULL DEFAULT ''", + "selected_material_code": "TEXT NOT NULL DEFAULT ''", + "selected_profile_json": "TEXT NOT NULL DEFAULT '{}'", + "selected_wall_thickness_mm": "REAL NOT NULL DEFAULT 0", + "net_weight_kg": "REAL NOT NULL DEFAULT 0", + } + for column, definition in bom_additions.items(): + if column not in bom_columns: + conn.execute(f"ALTER TABLE bom_components ADD COLUMN {column} {definition}") + archive_columns = { + str(row[1]) for row in conn.execute("PRAGMA table_info(cost_quote_archives)").fetchall() + } + archive_additions = { + "factory_profit_rate": "REAL NOT NULL DEFAULT 0", + "factory_profit_amount": "REAL NOT NULL DEFAULT 0", + "factory_output_tax_rate": "REAL NOT NULL DEFAULT 0", + "factory_output_tax_amount": "REAL NOT NULL DEFAULT 0", + "bom_factory_quote": "REAL NOT NULL DEFAULT 0", + } + for column, definition in archive_additions.items(): + if column not in archive_columns: + conn.execute(f"ALTER TABLE cost_quote_archives ADD COLUMN {column} {definition}") + + def _ensure_costing_standards(self) -> None: + now = self._now() + with self._connect() as conn: + if not conn.execute("SELECT 1 FROM labor_rate_versions LIMIT 1").fetchone(): + cursor = conn.execute( + "INSERT INTO labor_rate_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ("initial-2026-09", "系统初始工种完全成本,上线后用工厂实际数据校准", now), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + "INSERT INTO labor_rates(version_id,worker_code,worker_name,hourly_rate) VALUES(?,?,?,?)", + [(version_id, code, name, rate) for code, name, rate in self.DEFAULT_LABOR_RATES], + ) + calibrated_labor = conn.execute( + "SELECT version_id FROM labor_rate_versions WHERE version_label=?", + (self.LABOR_CALIBRATION_VERSION,), + ).fetchone() + if not calibrated_labor: + conn.execute("UPDATE labor_rate_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO labor_rate_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ( + self.LABOR_CALIBRATION_VERSION, + "中国制造业及佛山家具岗位公开薪资校准;费率为企业有效生产小时完全成本,已含雇主负担与正常效率,不再除以效率", + now, + ), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + "INSERT INTO labor_rates(version_id,worker_code,worker_name,hourly_rate) VALUES(?,?,?,?)", + [(version_id, code, name, rate) for code, name, rate in self.DEFAULT_LABOR_RATES], + ) + if not conn.execute("SELECT 1 FROM process_standard_versions LIMIT 1").fetchone(): + cursor = conn.execute( + "INSERT INTO process_standard_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ("initial-2026-09", "系统初始标准工时,需根据车间报工数据持续校准", now), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO process_time_standards( + version_id,process_code,process_name,worker_code,setup_minutes,unit_minutes, + area_minutes_m2,door_minutes,hole_minutes,slot_minutes,joint_minutes,profile_minutes_m + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """, + [(version_id, *row) for row in self.DEFAULT_PROCESS_STANDARDS], + ) + calibrated_process = conn.execute( + "SELECT version_id FROM process_standard_versions WHERE version_label=?", + (self.PROCESS_CALIBRATION_VERSION,), + ).fetchone() + if not calibrated_process: + conn.execute("UPDATE process_standard_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO process_standard_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ( + self.PROCESS_CALIBRATION_VERSION, + "互斥工艺台账:开料按板张或体积、孔槽榫和雕刻按证据、白坯与层间打磨分列、贴面与油漆分列、拆装与整装分线、包装按动作计时", + now, + ), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO process_time_standards( + version_id,process_code,process_name,worker_code,setup_minutes,unit_minutes, + area_minutes_m2,door_minutes,hole_minutes,slot_minutes,joint_minutes,profile_minutes_m + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """, + [(version_id, *row) for row in self.DEFAULT_PROCESS_STANDARDS], + ) + if not conn.execute("SELECT 1 FROM packaging_price_versions LIMIT 1").fetchone(): + cursor = conn.execute( + "INSERT INTO packaging_price_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ("initial-2026-09", "系统初始包材单价,需替换为实际供应商含税报价", now), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + "INSERT INTO packaging_material_prices(version_id,material_code,material_name,unit,unit_price,waste_rate) VALUES(?,?,?,?,?,?)", + [(version_id, *row) for row in self.DEFAULT_PACKAGING_PRICES], + ) + calibrated_packaging = conn.execute( + "SELECT version_id FROM packaging_price_versions WHERE version_label=?", + (self.PACKAGING_CALIBRATION_VERSION,), + ).fetchone() + if not calibrated_packaging: + conn.execute("UPDATE packaging_price_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO packaging_price_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ( + self.PACKAGING_CALIBRATION_VERSION, + "公司纸箱内部成本标准:经济级使用90cm推拉餐柜BOM 4.088㎡×4.00元/㎡=16.352元基准,标准级按基准1.50倍为6.00元/㎡,加强级按基准2.00倍为8.00元/㎡;层数、楞型、克重、耐破和边压待供应商确认;包材损耗统一2%且只计一次", + now, + ), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + "INSERT INTO packaging_material_prices(version_id,material_code,material_name,unit,unit_price,waste_rate) VALUES(?,?,?,?,?,?)", + [(version_id, *row) for row in self.DEFAULT_PACKAGING_PRICES], + ) + process_version = conn.execute( + "SELECT version_id FROM process_standard_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if process_version: + existing = { + str(row[0]) for row in conn.execute( + "SELECT process_code FROM process_time_standards WHERE version_id=?", (process_version[0],) + ).fetchall() + } + missing = [row for row in self.DEFAULT_PROCESS_STANDARDS if row[0] not in existing] + if missing: + conn.executemany( + """ + INSERT INTO process_time_standards( + version_id,process_code,process_name,worker_code,setup_minutes,unit_minutes, + area_minutes_m2,door_minutes,hole_minutes,slot_minutes,joint_minutes,profile_minutes_m + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """, + [(int(process_version[0]), *row) for row in missing], + ) + if not conn.execute("SELECT 1 FROM panel_price_versions LIMIT 1").fetchone(): + cursor = conn.execute( + "INSERT INTO panel_price_versions(version_label,source_note,source_urls_json,created_at,is_active) VALUES(?,?,?,?,1)", + ( + "public-benchmark-2026-09", + "政府造价信息公开价作为估算种子;E0/ENF与品牌溢价为可配置估算,成交前须换成供应商含税到厂报价", + json.dumps([ + "https://js.shaanxi.gov.cn/sy/yw/zjglfw/zjxx/202512/P020251218354109561401.pdf", + "https://zzjsj.zhengzhou.gov.cn/attachment/2026%E5%B9%B4%E7%AC%AC1%E5%AD%A3%E5%BA%A6%E9%83%91%E5%B7%9E%E5%B8%82%E5%BB%BA%E8%AE%BE%E5%B7%A5%E7%A8%8B%E6%9D%90%E6%96%99%E4%BB%B7%E6%A0%BC%E4%BF%A1%E6%81%AF.pdf", + ]), + now, + ), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO engineered_panel_prices( + version_id,material_code,material_name,thickness_mm, + price_low_per_sheet,price_mid_per_sheet,price_high_per_sheet, + density_kg_m3,nesting_yield_rate,source_note + ) VALUES(?,?,?,?,?,?,?,?,?,?) + """, + [(version_id, *row) for row in self.DEFAULT_PANEL_PRICES], + ) + panel_version = conn.execute( + "SELECT version_id FROM panel_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if panel_version: + existing_panels = { + (str(row[0]), float(row[1])) for row in conn.execute( + "SELECT material_code,thickness_mm FROM engineered_panel_prices WHERE version_id=?", + (panel_version[0],), + ).fetchall() + } + missing_panels = [ + row for row in self.DEFAULT_PANEL_PRICES + if (str(row[0]), float(row[2])) not in existing_panels + ] + if missing_panels: + conn.executemany( + """ + INSERT INTO engineered_panel_prices( + version_id,material_code,material_name,thickness_mm, + price_low_per_sheet,price_mid_per_sheet,price_high_per_sheet, + density_kg_m3,nesting_yield_rate,source_note + ) VALUES(?,?,?,?,?,?,?,?,?,?) + """, + [(int(panel_version[0]), *row) for row in missing_panels], + ) + if not conn.execute("SELECT 1 FROM edge_price_versions LIMIT 1").fetchone(): + cursor = conn.execute( + "INSERT INTO edge_price_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ("public-benchmark-2026-09", "公开厂家/市场参考价种子,含8%裁切接头损耗;正式报价应替换为实际封边条和胶黏剂采购价", now), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + "INSERT INTO edge_material_prices(version_id,edge_code,edge_name,unit,price_low,price_mid,price_high,waste_rate) VALUES(?,?,?,?,?,?,?,?)", + [(version_id, *row) for row in self.DEFAULT_EDGE_PRICES], + ) + edge_version = conn.execute( + "SELECT version_id FROM edge_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if edge_version: + existing_edges = { + str(row[0]) for row in conn.execute( + "SELECT edge_code FROM edge_material_prices WHERE version_id=?", (edge_version[0],) + ).fetchall() + } + missing_edges = [row for row in self.DEFAULT_EDGE_PRICES if row[0] not in existing_edges] + if missing_edges: + conn.executemany( + "INSERT INTO edge_material_prices(version_id,edge_code,edge_name,unit,price_low,price_mid,price_high,waste_rate) VALUES(?,?,?,?,?,?,?,?)", + [(int(edge_version[0]), *row) for row in missing_edges], + ) + metal_version = conn.execute( + "SELECT version_id FROM metal_price_versions WHERE version_label=?", + (self.METAL_PRICE_VERSION,), + ).fetchone() + if not metal_version: + conn.execute("UPDATE metal_price_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO metal_price_versions(version_label,source_note,source_urls_json,benchmark_date,created_at,is_active) VALUES(?,?,?,?,?,1)", + ( + self.METAL_PRICE_VERSION, + "中国公开市场价格作为家具金属型材概算种子;已分开原料基准与制管/挤压小批溢价,正式工程报价必须用供应商含税到厂价校准", + json.dumps( + [ + "https://www.stats.gov.cn/sj/zxfb/202609/t20260903_1965182.html", + "https://market.cnal.com/nanhai/2026/08-21/1787286331668399.shtml", + "https://openstd.samr.gov.cn/bzgk/std/newGbInfo?hcno=D2862F3B35CBDC75C7262BEAEE5B47FD", + "https://openstd.samr.gov.cn/bzgk/std/newGbInfo?hcno=F4968B3F66DB0556A3FCC3C4724341AF", + ], + ensure_ascii=False, + ), + "2026-09-04", + now, + ), + ) + metal_version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO metal_material_prices( + version_id,material_code,material_name,density_kg_m3, + price_low_kg,price_mid_kg,price_high_kg,waste_rate,source_note + ) VALUES(?,?,?,?,?,?,?,?,?) + """, + [(metal_version_id, *row) for row in self.DEFAULT_METAL_MATERIAL_PRICES], + ) + conn.executemany( + """ + INSERT INTO metal_profile_specs( + version_id,profile_code,profile_shape,display_name,width_mm,height_mm, + diameter_mm,wall_thickness_mm,theoretical_kg_m,recommended_use,standard_ref + ) VALUES(?,?,?,?,?,?,?,?,?,?,?) + """, + [(metal_version_id, *row) for row in self._default_metal_profile_specs()], + ) + metal_process_version = conn.execute( + "SELECT version_id FROM metal_process_price_versions WHERE version_label=?", + (self.METAL_PROCESS_VERSION,), + ).fetchone() + if not metal_process_version: + conn.execute("UPDATE metal_process_price_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO metal_process_price_versions(version_label,source_note,source_urls_json,benchmark_date,created_at,is_active) VALUES(?,?,?,?,?,1)", + ( + self.METAL_PROCESS_VERSION, + "金属工艺按切口、切割米数、折弯道数、焊缝米数、处理面积或重量计价;零驱动不收费,换线费按批量摊销", + json.dumps( + [ + "https://openstd.samr.gov.cn/bzgk/gb/newGbInfo?hcno=7BBEE95CF0808B6CA15153E77F354359&refer=outter", + "https://jst.nx.gov.cn/ztzl/gczj/zjtt/202605/P020260518392313525886.pdf", + ], + ensure_ascii=False, + ), + "2026-09-04", + now, + ), + ) + metal_process_version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO metal_process_prices( + version_id,process_code,process_name,driver_unit,price_low,price_mid, + price_high,setup_cost_batch,allowed_materials,source_note + ) VALUES(?,?,?,?,?,?,?,?,?,?) + """, + [(metal_process_version_id, *row) for row in self.DEFAULT_METAL_PROCESS_PRICES], + ) + company_metal_version = conn.execute( + "SELECT version_id FROM metal_company_benchmark_versions WHERE version_label=?", + (self.COMPANY_METAL_BENCHMARK_VERSION,), + ).fetchone() + if not company_metal_version: + conn.execute("UPDATE metal_company_benchmark_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO metal_company_benchmark_versions(version_label,source_table,benchmark_date,source_note,created_at,is_active) VALUES(?,?,?,?,?,1)", + ( + self.COMPANY_METAL_BENCHMARK_VERSION, + "JSDataMiddlePlatform.dbo.pm_bomBaseInfo", + "2026-09-04", + "公司钢材BOM只读聚合快照;已核验area为总延米、amount=area×unitPrice×coefficient。只对高置信度同材质同截面同壁厚记录启用每米价,其他规格继续按公开公斤价", + now, + ), + ) + company_metal_version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO metal_company_profile_benchmarks( + version_id,material_code,profile_shape,width_mm,height_mm,diameter_mm, + wall_thickness_mm,price_low_m,price_mid_m,price_high_m,waste_rate, + sample_count,sku_count,latest_at,confidence,source_note + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + [(company_metal_version_id, *row) for row in self.DEFAULT_COMPANY_METAL_PROFILE_BENCHMARKS], + ) + special_version = conn.execute( + "SELECT version_id FROM special_operation_price_versions WHERE version_label=?", + (self.SPECIAL_OPERATION_PRICE_VERSION,), + ).fetchone() + if not special_version: + conn.execute("UPDATE special_operation_price_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO special_operation_price_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ( + self.SPECIAL_OPERATION_PRICE_VERSION, + "公司提供的1688特殊操作额外费用明细;含耗材与不含耗材二选一,不与BOM包材重复累加", + now, + ), + ) + special_version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO special_operation_prices( + version_id,operation_code,operation_name,driver_unit, + price_excluding_material,price_including_material + ) VALUES(?,?,?,?,?,?) + """, + [(special_version_id, *row) for row in self.DEFAULT_SPECIAL_OPERATION_PRICES], + ) + connection_version = conn.execute( + "SELECT version_id FROM connection_rule_versions WHERE version_label=?", + (self.CONNECTION_RULE_VERSION,), + ).fetchone() + if not connection_version: + conn.execute("UPDATE connection_rule_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO connection_rule_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ( + self.CONNECTION_RULE_VERSION, + "材料特性驱动连接工艺:实木、人造板、金属和混合界面的允许/禁止规则;禁止人造板传统承力榫卯", + now, + ), + ) + connection_version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO material_connection_rules( + version_id,material_family,process_code,is_allowed,release_level,rule_note + ) VALUES(?,?,?,?,?,?) + """, + [(connection_version_id, *row) for row in self.DEFAULT_MATERIAL_CONNECTION_RULES], + ) + template_version = conn.execute( + "SELECT version_id FROM hardware_drilling_template_versions WHERE version_label=?", + (self.HARDWARE_TEMPLATE_VERSION,), + ).fetchone() + if not template_version: + conn.execute("UPDATE hardware_drilling_template_versions SET is_active=0 WHERE is_active=1") + cursor = conn.execute( + "INSERT INTO hardware_drilling_template_versions(version_label,source_note,created_at,is_active) VALUES(?,?,?,1)", + ( + self.HARDWARE_TEMPLATE_VERSION, + "公司量产连接件预钻孔模板;铰链、滑轨等品牌五金仍必须绑定厂商SKU/CAD模板才能正式发布", + now, + ), + ) + template_version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO hardware_drilling_templates( + template_id,version_id,hardware_code,display_name,allowed_material_families, + parameters_json,source_note + ) VALUES(?,?,?,?,?,?,?) + """, + [ + ( + template_id, + template_version_id, + hardware_code, + display_name, + material_families, + json.dumps(parameters, ensure_ascii=False), + source_note, + ) + for template_id, hardware_code, display_name, material_families, parameters, source_note + in self.DEFAULT_HARDWARE_DRILLING_TEMPLATES + ], + ) + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + @staticmethod + def context_key(conversation_id: str, user_id: str, session_key: str) -> str: + raw = "|".join([str(user_id or ""), str(conversation_id or ""), str(session_key or "")]) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + @staticmethod + def _float(value, default=None): + try: + return float(value) + except (TypeError, ValueError): + return default + + def _active_connection_rules(self) -> tuple[dict, list[dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM connection_rule_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("连接工艺规则库未初始化") + rows = conn.execute( + "SELECT * FROM material_connection_rules WHERE version_id=? ORDER BY material_family,process_code", + (version["version_id"],), + ).fetchall() + return dict(version), [dict(row) for row in rows] + + def _active_hardware_templates(self) -> tuple[dict, dict[str, dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM hardware_drilling_template_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("五金排钻模板库未初始化") + rows = conn.execute( + "SELECT * FROM hardware_drilling_templates WHERE version_id=? ORDER BY hardware_code", + (version["version_id"],), + ).fetchall() + templates: dict[str, dict] = {} + for raw in rows: + row = dict(raw) + try: + row["parameters"] = json.loads(str(row.get("parameters_json") or "{}")) + except json.JSONDecodeError: + row["parameters"] = {} + templates[str(row.get("hardware_code") or "")] = row + return dict(version), templates + + @staticmethod + def _metal_profile_kg_m( + shape: str, + *, + width_mm: float = 0.0, + height_mm: float = 0.0, + diameter_mm: float = 0.0, + wall_thickness_mm: float, + density_kg_m3: float = 7850.0, + ) -> float: + t = max(0.0, float(wall_thickness_mm)) + if shape in {"square_tube", "rect_tube"}: + width = max(0.0, float(width_mm)) + height = max(0.0, float(height_mm or width_mm)) + inner_w = max(0.0, width - 2 * t) + inner_h = max(0.0, height - 2 * t) + area_mm2 = width * height - inner_w * inner_h + elif shape == "round_tube": + diameter = max(0.0, float(diameter_mm)) + inner_d = max(0.0, diameter - 2 * t) + area_mm2 = math.pi / 4 * (diameter * diameter - inner_d * inner_d) + elif shape in {"flat_bar", "plate"}: + area_mm2 = max(0.0, float(width_mm)) * t + else: + raise SolidWoodCostError(f"不支持的金属型材截面:{shape}") + return area_mm2 * float(density_kg_m3) / 1_000_000 + + @classmethod + def _default_metal_profile_specs(cls) -> list[tuple]: + rows: list[tuple] = [] + + def add(shape: str, width: float, height: float, diameter: float, thickness: float, use: str) -> None: + if shape == "square_tube": + name = f"{width:g}×{width:g}×{thickness:g}方管" + code = f"SQ-{width:g}-{thickness:g}" + elif shape == "rect_tube": + name = f"{width:g}×{height:g}×{thickness:g}矩形管" + code = f"RT-{width:g}-{height:g}-{thickness:g}" + else: + name = f"Φ{diameter:g}×{thickness:g}圆管" + code = f"RD-{diameter:g}-{thickness:g}" + kg_m = cls._metal_profile_kg_m( + shape, + width_mm=width, + height_mm=height, + diameter_mm=diameter, + wall_thickness_mm=thickness, + ) + rows.append( + ( + code, + shape, + name, + width, + height, + diameter, + thickness, + round(kg_m, 6), + use, + "GB/T 6728-2025;成品家具同时按GB/T 3325-2024验证", + ) + ) + + square_specs = { + 15: (0.5, 0.6, 0.8, 1.0), + 20: (0.5, 0.6, 0.8, 1.0, 1.2, 1.5), + 25: (0.6, 1.0, 1.2, 1.5, 2.0), + 30: (0.6, 1.0, 1.2, 1.5, 2.0), + 40: (0.6, 1.2, 1.5, 2.0), + 50: (1.5, 2.0), + } + for size, thicknesses in square_specs.items(): + use = "装饰框/轻载" if size <= 20 else ("桌椅柜架/中载" if size <= 30 else "桌架/重载支撑") + for thickness in thicknesses: + add("square_tube", size, size, 0, thickness, use) + rect_specs = { + (20, 10): (0.8, 1.0), + (30, 15): (0.6, 1.0, 1.2), + (40, 20): (0.6, 1.0, 1.2, 1.5), + (50, 25): (1.2, 1.5, 2.0), + (60, 30): (1.5, 2.0), + (80, 40): (1.5, 2.0), + } + for (width, height), thicknesses in rect_specs.items(): + use = "横撑/层架" if width <= 40 else "桌架/床架主梁" + for thickness in thicknesses: + add("rect_tube", width, height, 0, thickness, use) + round_specs = { + 16: (0.8, 0.9, 1.0), + 19: (0.8, 1.0), + 22: (1.0, 1.2), + 25: (1.0, 1.2, 1.5), + 32: (1.0, 1.2, 1.5), + 38: (1.2, 1.5), + 50: (1.5, 2.0), + } + for diameter, thicknesses in round_specs.items(): + use = "装饰/椅架" if diameter <= 25 else "桌腿/主支撑" + for thickness in thicknesses: + add("round_tube", 0, 0, diameter, thickness, use) + return rows + + @staticmethod + def _median_positive(values: list[float]) -> float | None: + cleaned = [float(value) for value in values if value is not None and float(value) > 0] + return float(statistics.median(cleaned)) if cleaned else None + + @staticmethod + def _sql_nstring(value: str) -> str: + return "N'" + str(value or "").replace("'", "''") + "'" + + @classmethod + def _historical_product_keywords(cls, analysis: dict) -> list[str]: + text = f"{analysis.get('product_type') or ''} {analysis.get('product_name') or ''}" + matches = [word for word in cls.HISTORICAL_PRODUCT_KEYWORDS if word in text] + matches.sort(key=len, reverse=True) + if matches: + # One precise family is faster and less noisy than a broad OR query. Keep a + # second label only when it is equally specific; e.g. 餐边柜 must not + # degrade into a broad 边柜 search that also admits vanity combinations. + longest = len(matches[0]) + return [word for word in matches if len(word) == longest][:2] + suffix = next((word for word in ("柜", "桌", "床", "椅", "几", "架") if word in text), "") + return [suffix] if suffix else [] + + @classmethod + def _historical_material_code(cls, selections: dict) -> str: + panel_code = str(selections.get("panel_code") or "") + return panel_code if panel_code in cls.HISTORICAL_MATERIAL_TERMS else "SOLID_WOOD" + + @classmethod + def _requested_solid_species_terms(cls, selections: dict) -> tuple[str, ...]: + requested = str(selections.get("main_species") or "").lower() + for aliases, terms in cls.HISTORICAL_SOLID_SPECIES: + if any(alias.lower() in requested for alias in aliases): + return tuple(term.lower() for term in terms) + return () + + @staticmethod + def _strip_decorative_wood_colors(value: str) -> str: + text = str(value or "").lower() + text = re.sub( + r"(?:北美|美国|俄罗斯|国产)?(?:白橡木?|红橡木?|橡木|黑胡桃木?|胡桃木|白蜡木?|榆木|柚木|樱桃木|枫木|松木|桐木)(?:色|纹)", + " ", + text, + ) + return re.sub(r"(?:原木|木纹)(?:色|纹)", " ", text) + + @classmethod + def _historical_material_match( + cls, + material_code: str, + selections: dict, + board_rows: list[dict], + ) -> tuple[bool, float]: + """Require the selected material family/species to dominate the historical board BOM.""" + requested_species = cls._requested_solid_species_terms(selections) + panel_terms = tuple(term.lower() for term in cls.HISTORICAL_MATERIAL_TERMS.get(material_code, ())) + artificial_markers = ("密度板", "中纤板", "中密度板", "刨花板", "颗粒板", "多层板", "胶合板", "生态板", "三合板") + generic_solid_terms = tuple( + term.lower() + for _aliases, terms in cls.HISTORICAL_SOLID_SPECIES + for term in terms + ) + ("实木",) + matched_weight = 0.0 + total_weight = 0.0 + for row in board_rows: + weight = cls._float(row.get("amount"), 0.0) or cls._float(row.get("area"), 0.0) or 1.0 + weight = max(0.0, float(weight)) + total_weight += weight + raw_name = str(row.get("name") or "").lower() + if material_code == "SOLID_WOOD": + material_name = cls._strip_decorative_wood_colors(raw_name) + is_artificial = any(marker in material_name for marker in artificial_markers) + required_terms = requested_species or generic_solid_terms + row_matches = not is_artificial and any(term in material_name for term in required_terms) + else: + row_matches = any(term in raw_name for term in panel_terms) + if row_matches: + matched_weight += weight + ratio = matched_weight / total_weight if total_weight > 0 else 0.0 + return ratio >= 0.50, ratio + + def _history_cache_get(self, cache_key: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT payload_json FROM historical_bom_cache WHERE cache_key=? AND expires_at_epoch>?", + (cache_key, time.time()), + ).fetchone() + if not row: + return None + try: + payload = json.loads(row[0]) + except (TypeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + def _history_cache_set(self, cache_key: str, payload: dict, ttl_seconds: int = 21600) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO historical_bom_cache(cache_key,payload_json,created_at,expires_at_epoch) + VALUES(?,?,?,?) + ON CONFLICT(cache_key) DO UPDATE SET + payload_json=excluded.payload_json, + created_at=excluded.created_at, + expires_at_epoch=excluded.expires_at_epoch + """, + (cache_key, json.dumps(payload, ensure_ascii=False), self._now(), time.time() + ttl_seconds), + ) + + def _company_bom_query(self, sql: str, row_limit: int) -> list[dict]: + if not self.history_query_command.is_file() or not self.history_query_socket.exists(): + raise SolidWoodCostError("公司BOM只读查询通道不可用") + proc = subprocess.run( + [ + str(self.history_query_command), + "query", + "--database", + "company", + "--socket", + str(self.history_query_socket), + "--limit", + str(max(1, min(int(row_limit), 8000))), + "--sql", + sql, + ], + capture_output=True, + text=True, + timeout=self.history_query_timeout, + check=False, + ) + if proc.returncode != 0: + raise SolidWoodCostError((proc.stderr or proc.stdout or "公司BOM查询失败").strip()[-500:]) + try: + payload = json.loads(proc.stdout or "{}") + except json.JSONDecodeError as exc: + raise SolidWoodCostError("公司BOM查询返回格式错误") from exc + if not isinstance(payload, dict) or payload.get("ok") is not True: + raise SolidWoodCostError(str((payload or {}).get("error") or "公司BOM查询失败")[-500:]) + result_sets = payload.get("resultSets") or [] + result = result_sets[0] if result_sets else payload + columns = [str(item) for item in (result.get("columns") or [])] + rows: list[dict] = [] + for raw in result.get("rows") or []: + if isinstance(raw, dict): + rows.append(dict(raw)) + elif isinstance(raw, (list, tuple)): + rows.append({columns[index]: value for index, value in enumerate(raw) if index < len(columns)}) + return rows + + @staticmethod + def _historical_candidate_dimensions(*values: str) -> list[float]: + text = " ".join(str(value or "") for value in values) + match = re.search( + r"(? list[dict]: + by_name: dict[str, list[dict]] = {} + for sample in samples: + seen: set[str] = set() + for line in sample.get("detail_lines") or []: + if str(line.get("category") or "") != category: + continue + name = re.sub(r"\s+", "", str(line.get("name") or "")).strip() + if not name: + continue + current = dict(line) + current["sample_presence"] = 0 if name in seen else 1 + seen.add(name) + by_name.setdefault(name, []).append(current) + minimum_presence = max(1, math.ceil(len(samples) * 0.5)) + common: list[dict] = [] + for name, lines in by_name.items(): + presence = sum(int(line.get("sample_presence") or 0) for line in lines) + if presence < minimum_presence: + continue + quantities = [cls._float(line.get("quantity"), 0.0) or 0.0 for line in lines] + unit_prices = [cls._float(line.get("unit_price"), 0.0) or 0.0 for line in lines] + amounts = [cls._float(line.get("amount"), 0.0) or 0.0 for line in lines] + common.append( + { + "name": name, + "unit": next((str(line.get("unit") or "") for line in lines if line.get("unit")), ""), + "median_quantity": cls._median_positive(quantities) or 0.0, + "median_unit_price": cls._median_positive(unit_prices) or 0.0, + "median_amount": cls._median_positive(amounts) or 0.0, + "sample_presence": presence, + "sample_count": len(samples), + } + ) + common.sort(key=lambda item: (-int(item["sample_presence"]), -float(item["median_amount"]), item["name"])) + return common[:limit] + + def _historical_bom_benchmark(self, analysis: dict, selections: dict) -> dict: + """Return a bounded, read-only analogue benchmark; never block the quote.""" + keywords = self._historical_product_keywords(analysis) + material_code = self._historical_material_code(selections) + overall = analysis.get("overall_dimensions_mm") or {} + cache_raw = json.dumps( + { + "match_version": 2, + "keywords": keywords, + "material": material_code, + "main_species": str(selections.get("main_species") or ""), + "aux_species": str(selections.get("aux_species") or ""), + "dims": [round(float(overall.get(axis) or 0), -1) for axis in ("x", "y", "z")], + "parts": sum(int(item.get("quantity") or 1) for item in analysis.get("components") or []), + }, + ensure_ascii=False, + sort_keys=True, + ) + cache_key = hashlib.sha256(cache_raw.encode("utf-8")).hexdigest() + if not self.history_enabled: + return {"status": "disabled", "confidence": "none", "sample_count": 0} + cached = self._history_cache_get(cache_key) + if cached: + cached["cache_hit"] = True + return cached + if not keywords: + return {"status": "no_match", "confidence": "none", "sample_count": 0, "reason": "未识别可匹配的家具品类"} + try: + keyword_clauses = " OR ".join( + f"s.name LIKE {self._sql_nstring('%' + word + '%')}" for word in keywords + ) + sku_rows = self._company_bom_query( + """ + USE [JSDataMiddlePlatform]; + SELECT TOP (60) + s.sku_id, s.name AS sku_name, s.properties_value AS sku_spec, s.modified + FROM dbo.jst_sku s WITH (NOLOCK) + WHERE ISNULL(s.enabled,0)=1 AND (""" + + keyword_clauses + + ") ORDER BY s.modified DESC, s.sku_id;", + 80, + ) + sku_ids = [str(row.get("sku_id") or "").strip() for row in sku_rows if str(row.get("sku_id") or "").strip()] + if not sku_ids: + return {"status": "no_match", "confidence": "none", "sample_count": 0, "reason": "同品类已启用SKU为空"} + sku_literals = ",".join(self._sql_nstring(value) for value in sku_ids) + detail_rows = self._company_bom_query( + """ + USE [JSDataMiddlePlatform]; + SELECT TOP (8000) + b.skucode AS sku_code, b.category, b.name, b.unit, b.quantity, + b.length, b.width, b.area, b.edgeLength AS edge_length, + b.unitPrice AS unit_price, b.amount, b.note, b.createdAt AS created_at + FROM dbo.pm_bomBaseInfo b WITH (NOLOCK) + WHERE b.skucode IN (""" + + sku_literals + + ") AND b.category IN (N'板材',N'封边',N'配件',N'包材',N'人工+杂费',N'总材料费',N'总成本') ORDER BY b.skucode,b.Id;", + 8000, + ) + except Exception as exc: + return { + "status": "unavailable", + "confidence": "none", + "sample_count": 0, + "reason": re.sub(r"\s+", " ", str(exc)).strip()[-240:] or "历史BOM查询失败", + } + metadata = {str(row.get("sku_id") or ""): row for row in sku_rows} + grouped: dict[str, list[dict]] = {} + for row in detail_rows: + grouped.setdefault(str(row.get("sku_code") or ""), []).append(row) + current_dims = sorted([float(overall.get(axis) or 1) for axis in ("x", "y", "z")], reverse=True) + current_parts = max(1, sum(int(item.get("quantity") or 1) for item in analysis.get("components") or [])) + candidates: list[dict] = [] + for sku_code, rows in grouped.items(): + meta = metadata.get(sku_code) or {} + board_rows = [row for row in rows if row.get("category") == "板材"] + if not board_rows: + continue + material_match, material_match_ratio = self._historical_material_match( + material_code, selections, board_rows + ) + product_dims = self._historical_candidate_dimensions(meta.get("sku_name"), meta.get("sku_spec")) + max_length = max([self._float(row.get("length"), 0.0) or 0.0 for row in board_rows] or [0.0]) + max_width = max([self._float(row.get("width"), 0.0) or 0.0 for row in board_rows] or [0.0]) + if len(product_dims) < 2 and max_length > 0 and max_width > 0: + product_dims = sorted([max_length, max_width], reverse=True) + comparable = min(len(product_dims), len(current_dims), 3) + if comparable: + dimension_error = sum( + abs(math.log(max(1.0, product_dims[index]) / max(1.0, current_dims[index]))) + for index in range(comparable) + ) / comparable + dimension_score = math.exp(-dimension_error) + else: + dimension_score = 0.35 + historical_parts = sum(max(1, int(self._float(row.get("quantity"), 1) or 1)) for row in board_rows) + part_score = min(current_parts, historical_parts) / max(current_parts, historical_parts) + name_text = f"{meta.get('sku_name') or ''} {meta.get('sku_spec') or ''}" + keyword_score = sum(1 for word in keywords if word in name_text) / max(1, len(keywords)) + recency_text = str(meta.get("modified") or max((str(row.get("created_at") or "") for row in rows), default="")) + recency_score = 1.0 if recency_text[:4] >= "2025" else 0.5 + score = 0.50 * keyword_score + 0.18 * dimension_score + 0.12 * part_score + 0.15 * float(material_match) + 0.05 * recency_score + + def category_values(category: str, *, use_max: bool = False) -> list[float]: + values = [self._float(row.get("amount"), 0.0) or 0.0 for row in rows if row.get("category") == category] + return [max(values)] if use_max and values else values + + candidates.append( + { + "sku_code": sku_code, + "sku_name": str(meta.get("sku_name") or "")[:160], + "sku_spec": str(meta.get("sku_spec") or "")[:160], + "score": round(score, 4), + "material_match": material_match, + "material_match_ratio": round(material_match_ratio, 4), + "dimension_score": round(dimension_score, 4), + "board_part_count": historical_parts, + "board_area_m2": sum(self._float(row.get("area"), 0.0) or 0.0 for row in board_rows), + "edge_length_m": sum(self._float(row.get("edge_length"), 0.0) or 0.0 for row in rows if row.get("category") == "封边"), + "material_cost": sum(category_values("总材料费", use_max=True)), + "hardware_cost": sum(category_values("配件")), + "packaging_cost": sum(category_values("包材")), + "labor_misc_cost": sum(category_values("人工+杂费")), + "total_cost": sum(category_values("总成本", use_max=True)), + "latest_bom_at": max((str(row.get("created_at") or "") for row in rows), default=""), + "detail_lines": rows, + } + ) + candidates.sort(key=lambda item: (-float(item["score"]), item["sku_code"])) + samples = [ + item for item in candidates + if bool(item["material_match"]) and float(item["score"]) >= 0.40 + ][:5] + if not samples: + return { + "status": "no_match", + "confidence": "none", + "sample_count": 0, + "reason": "无同材种/同材料家族且结构相似的历史SKU;木纹色名不作为实木证据", + } + average_score = sum(float(item["score"]) for item in samples) / len(samples) + matched_materials = sum(bool(item["material_match"]) for item in samples) + confidence = "high" if len(samples) >= 4 and average_score >= 0.72 and matched_materials >= 3 else "medium" if len(samples) >= 3 and average_score >= 0.55 else "low" + metric_names = ("material_cost", "hardware_cost", "packaging_cost", "labor_misc_cost", "total_cost", "board_area_m2", "edge_length_m", "board_part_count") + metrics = { + name: self._median_positive([self._float(item.get(name), 0.0) or 0.0 for item in samples]) + for name in metric_names + } + common_hardware = self._common_historical_lines(samples, "配件") + common_packaging = self._common_historical_lines(samples, "包材", limit=10) + payload = { + "status": "matched", + "confidence": confidence, + "sample_count": len(samples), + "average_score": round(average_score, 4), + "keywords": keywords, + "material_code": material_code, + "material_match_count": matched_materials, + "metrics": metrics, + "common_hardware": common_hardware, + "common_packaging": common_packaging, + "samples": [ + {key: item.get(key) for key in ( + "sku_code", "sku_name", "sku_spec", "score", "material_match", "material_match_ratio", "dimension_score", + "board_part_count", "board_area_m2", "edge_length_m", "material_cost", "hardware_cost", + "packaging_cost", "labor_misc_cost", "total_cost", "latest_bom_at", + )} + for item in samples + ], + "source": "JSDataMiddlePlatform.dbo.pm_bomBaseInfo (read-only)", + "usage_rule": "相似历史SKU只用于复核BOM完整性、五金金额和成本偏差;不替代当前图片拆件、用户确认的材质和当前价格库", + "cache_hit": False, + } + self._history_cache_set(cache_key, payload) + return payload + + @classmethod + def _density_range(cls, value) -> tuple[float | None, float | None, float | None]: + nums = [cls._float(x) for x in re.findall(r"\d+(?:\.\d+)?", str(value or ""))] + nums = [x for x in nums if x is not None] + if not nums: + return None, None, None + low, high = nums[0], nums[-1] + return low, high, (low + high) / 2 + + def _ensure_price_data(self) -> None: + with self._connect() as conn: + count = conn.execute("SELECT COUNT(*) FROM wood_species_prices").fetchone()[0] + if count: + return + if not self.price_xlsx.is_file(): + raise SolidWoodCostError(f"实木材种价格表不存在:{self.price_xlsx.name}") + self.import_price_workbook(self.price_xlsx) + + def import_price_workbook(self, path: pathlib.Path) -> dict: + data = path.read_bytes() + digest = hashlib.sha256(data).hexdigest() + with self._connect() as conn: + existing = conn.execute( + "SELECT * FROM wood_price_versions WHERE file_sha256=?", (digest,) + ).fetchone() + if existing: + return dict(existing) + + workbook = load_workbook(io.BytesIO(data), read_only=True, data_only=True) + if "汇总-全档次一览" not in workbook.sheetnames: + raise SolidWoodCostError("价格表缺少‘汇总-全档次一览’工作表") + sheet = workbook["汇总-全档次一览"] + title = str(sheet["A1"].value or "") + version_match = re.search(r"(20\d{2})年\s*(\d{1,2})月", title) + version_label = ( + f"{version_match.group(1)}-{int(version_match.group(2)):02d}-07" + if version_match + else path.stem + ) + rows: list[dict] = [] + for row_number in range(4, sheet.max_row + 1): + serial = sheet.cell(row_number, 1).value + species = str(sheet.cell(row_number, 2).value or "").strip() + if not species or not isinstance(serial, (int, float)): + continue + density_min, density_max, density_mid = self._density_range(sheet.cell(row_number, 4).value) + price_basis = str(sheet.cell(row_number, 8).value or "立方价").strip() + cubic_low = self._float(sheet.cell(row_number, 9).value) + cubic_high = self._float(sheet.cell(row_number, 10).value) + cubic_mid = self._float(sheet.cell(row_number, 11).value) + ton_low = self._float(sheet.cell(row_number, 12).value) + ton_high = self._float(sheet.cell(row_number, 13).value) + ton_mid = self._float(sheet.cell(row_number, 14).value) + if cubic_low is None and ton_low is not None and density_mid: + cubic_low = ton_low * density_mid + if cubic_high is None and ton_high is not None and density_mid: + cubic_high = ton_high * density_mid + if cubic_mid is None and ton_mid is not None and density_mid: + cubic_mid = ton_mid * density_mid + if not cubic_mid or cubic_mid <= 0: + raise SolidWoodCostError(f"价格表第{row_number}行缺少可用的立方中值") + yield_value = self._float(sheet.cell(row_number, 18).value, 33.0) or 33.0 + source = { + "serial": int(serial), + "market_position": sheet.cell(row_number, 7).value, + "density_note": sheet.cell(row_number, 15).value, + "usage_note": sheet.cell(row_number, 17).value, + } + rows.append( + { + "source_row": row_number, + "species_name": species, + "grade": str(sheet.cell(row_number, 3).value or ""), + "density_min": density_min, + "density_max": density_max, + "density_mid": density_mid, + "price_basis": price_basis, + "cubic_low": cubic_low or cubic_mid, + "cubic_high": cubic_high or cubic_mid, + "cubic_mid": cubic_mid, + "ton_low": ton_low, + "ton_high": ton_high, + "ton_mid": ton_mid, + "yield_rate": min(1.0, max(0.01, yield_value / 100.0)), + "usage_note": str(sheet.cell(row_number, 17).value or ""), + "source_json": json.dumps(source, ensure_ascii=False), + } + ) + if len(rows) != 34: + raise SolidWoodCostError(f"价格表应有34个材种,实际识别到{len(rows)}个") + with self._connect() as conn: + conn.execute("UPDATE wood_price_versions SET is_active=0") + cursor = conn.execute( + "INSERT INTO wood_price_versions(version_label,file_sha256,source_filename,source_path,imported_at,record_count,is_active) VALUES(?,?,?,?,?,?,1)", + (version_label, digest, path.name, str(path), self._now(), len(rows)), + ) + version_id = int(cursor.lastrowid) + conn.executemany( + """ + INSERT INTO wood_species_prices( + version_id,source_row,species_name,grade,density_min,density_max,density_mid, + price_basis,cubic_low,cubic_high,cubic_mid,ton_low,ton_high,ton_mid, + yield_rate,usage_note,source_json + ) VALUES(:version_id,:source_row,:species_name,:grade,:density_min,:density_max,:density_mid, + :price_basis,:cubic_low,:cubic_high,:cubic_mid,:ton_low,:ton_high,:ton_mid, + :yield_rate,:usage_note,:source_json) + """, + [{**row, "version_id": version_id} for row in rows], + ) + return {"version_id": version_id, "version_label": version_label, "record_count": len(rows)} + + def _active_price_version(self) -> dict: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM wood_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not row: + raise SolidWoodCostError("当前没有可用的材种价格版本") + return dict(row) + + def _prices(self) -> list[dict]: + version = self._active_price_version() + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM wood_species_prices WHERE version_id=? ORDER BY cubic_mid", + (version["version_id"],), + ).fetchall() + return [dict(row) for row in rows] + + def _active_labor_rates(self) -> tuple[dict, dict[str, dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM labor_rate_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("当前没有可用的工种费率版本") + rows = conn.execute( + "SELECT * FROM labor_rates WHERE version_id=? ORDER BY rate_id", (version["version_id"],) + ).fetchall() + return dict(version), {str(row["worker_code"]): dict(row) for row in rows} + + def _active_process_standards(self) -> tuple[dict, list[dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM process_standard_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("当前没有可用的工序标准版本") + rows = conn.execute( + "SELECT * FROM process_time_standards WHERE version_id=? ORDER BY standard_id", (version["version_id"],) + ).fetchall() + return dict(version), [dict(row) for row in rows] + + def _active_packaging_prices(self) -> tuple[dict, dict[str, dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM packaging_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("当前没有可用的包材价格版本") + rows = conn.execute( + "SELECT * FROM packaging_material_prices WHERE version_id=? ORDER BY price_id", (version["version_id"],) + ).fetchall() + return dict(version), {str(row["material_code"]): dict(row) for row in rows} + + def _active_panel_prices(self) -> tuple[dict, list[dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM panel_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("当前没有可用的人造板价格版本") + rows = conn.execute( + "SELECT * FROM engineered_panel_prices WHERE version_id=? ORDER BY material_code,thickness_mm", + (version["version_id"],), + ).fetchall() + return dict(version), [dict(row) for row in rows] + + def _active_edge_prices(self) -> tuple[dict, dict[str, dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM edge_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("当前没有可用的封边材料价格版本") + rows = conn.execute( + "SELECT * FROM edge_material_prices WHERE version_id=? ORDER BY price_id", (version["version_id"],) + ).fetchall() + return dict(version), {str(row["edge_code"]): dict(row) for row in rows} + + def _active_metal_prices(self) -> tuple[dict, dict[str, dict], list[dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM metal_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("当前没有可用的金属材料价格版本") + prices = conn.execute( + "SELECT * FROM metal_material_prices WHERE version_id=? ORDER BY price_id", + (version["version_id"],), + ).fetchall() + specs = conn.execute( + "SELECT * FROM metal_profile_specs WHERE version_id=? ORDER BY spec_id", + (version["version_id"],), + ).fetchall() + return ( + dict(version), + {str(row["material_code"]): dict(row) for row in prices}, + [dict(row) for row in specs], + ) + + def _active_metal_process_prices(self) -> tuple[dict, dict[str, dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM metal_process_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + raise SolidWoodCostError("当前没有可用的金属工艺价格版本") + rows = conn.execute( + "SELECT * FROM metal_process_prices WHERE version_id=? ORDER BY price_id", + (version["version_id"],), + ).fetchall() + return dict(version), {str(row["process_code"]): dict(row) for row in rows} + + def _active_company_metal_benchmarks(self) -> tuple[dict, list[dict]]: + with self._connect() as conn: + version = conn.execute( + "SELECT * FROM metal_company_benchmark_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version: + return {}, [] + rows = conn.execute( + "SELECT * FROM metal_company_profile_benchmarks WHERE version_id=? ORDER BY benchmark_id", + (version["version_id"],), + ).fetchall() + return dict(version), [dict(row) for row in rows] + + @staticmethod + def _matching_company_metal_benchmark( + rows: list[dict], + *, + material_code: str, + shape: str, + width_mm: float, + height_mm: float, + diameter_mm: float, + wall_thickness_mm: float, + ) -> dict | None: + for row in rows: + if str(row.get("material_code") or "") != material_code: + continue + if str(row.get("profile_shape") or "") != shape: + continue + dimensions_match = ( + abs(float(row.get("width_mm") or 0) - width_mm) < 0.01 + and abs(float(row.get("height_mm") or 0) - height_mm) < 0.01 + and abs(float(row.get("diameter_mm") or 0) - diameter_mm) < 0.01 + and abs(float(row.get("wall_thickness_mm") or 0) - wall_thickness_mm) < 0.01 + ) + if dimensions_match: + return row + return None + + @classmethod + def _metal_code_from_text(cls, value: str) -> str: + normalized = re.sub(r"[\s()()·/\\_-]+", "", str(value or "")).lower() + for code, config in cls.METAL_MATERIALS.items(): + candidates = (str(config["name"]), *config["aliases"]) + if any(re.sub(r"[\s()()·/\\_-]+", "", candidate).lower() in normalized for candidate in candidates): + return code + return "" + + @staticmethod + def _is_metal_selection(selections: dict) -> bool: + return str(selections.get("material_structure") or "") in { + "solid_wood_metal", "engineered_panel_metal", "metal_all" + } + + @staticmethod + def _is_all_metal_selection(selections: dict) -> bool: + return str(selections.get("material_structure") or "") == "metal_all" + + @classmethod + def _metal_component(cls, item: dict, selections: dict) -> bool: + if cls._is_all_metal_selection(selections): + return True + text = f"{item.get('name') or ''} {item.get('material_group') or ''}" + custom = [str(x).strip() for x in (selections.get("metal_component_keywords") or []) if str(x).strip()] + if custom: + expanded_terms = [] + for token in custom: + expanded_terms.append(token) + normalized_token = re.sub(r"^(?:金属|铁制?|钢制?|不锈钢|铝制?)", "", token).strip() + expanded_terms.extend(cls.METAL_COMPONENT_SYNONYMS.get(normalized_token, (normalized_token,))) + for canonical, variants in cls.METAL_COMPONENT_SYNONYMS.items(): + if canonical in normalized_token or any(variant in normalized_token for variant in variants): + expanded_terms.extend(variants) + terms = tuple(dict.fromkeys(term for term in expanded_terms if term)) + else: + terms = cls.METAL_COMPONENT_TERMS + if any(token in text for token in ("木腿", "实木腿", "木脚", "实木脚")): + return False + return any(token in text for token in terms) + + def _metal_components(self, analysis: dict, selections: dict) -> list[dict]: + return [item for item in (analysis.get("components") or []) if self._metal_component(item, selections)] + + @classmethod + def _panel_code_from_text(cls, value: str) -> str: + normalized = re.sub(r"[\s()()·/\\_-]+", "", str(value or "")).lower() + for code, config in cls.PANEL_MATERIALS.items(): + candidates = (str(config["name"]), *config["aliases"]) + if any(re.sub(r"[\s()()·/\\_-]+", "", candidate).lower() in normalized for candidate in candidates): + return code + return "" + + @classmethod + def _is_panel_selection(cls, selections: dict) -> bool: + return bool(selections.get("panel_code") or cls._panel_code_from_text(str(selections.get("main_species") or ""))) + + def _panel_price_for(self, material_code: str, thickness_mm: float, emission_grade: str = "E0") -> dict: + version, prices = self._active_panel_prices() + candidates = [row for row in prices if row["material_code"] == material_code] + if not candidates: + raise SolidWoodCostError(f"人造板价格库中没有找到:{material_code}") + selected = min(candidates, key=lambda row: abs(float(row["thickness_mm"]) - float(thickness_mm))) + result = dict(selected) + grade = str(emission_grade or "E0").upper() + factor = float(self.PANEL_GRADE_FACTORS.get(grade, self.PANEL_GRADE_FACTORS["E0"])) + for key in ("price_low_per_sheet", "price_mid_per_sheet", "price_high_per_sheet"): + result[key] = float(result[key]) * factor + result["emission_grade"] = grade + result["grade_factor"] = factor + result["version"] = version + return result + + @staticmethod + def _panel_procurement_profile(selections: dict, base_yield: float, material_code: str) -> dict: + """Translate public sheet references to a factory purchasing basis. + + The adjustment depends only on purpose and batch size. It never reads a + matching SKU, matching BOM or finished-product cost. + """ + if material_code == "BACKBOARD": + return { + "price_factor": 1.0, + "nesting_yield_rate": max(float(base_yield), 0.94), + "basis": "独立薄型背板批量价;不套用主体板价格", + } + batch_qty = max(1, int(selections.get("batch_qty") or 1)) + purpose = str(selections.get("cost_purpose") or "概算") + if purpose in {"实际成本复核", "工程报价"} and batch_qty >= 50: + factor, effective_yield = 0.76, 0.93 + basis = "50件以上工厂批量采购:公开参考价×76%,跨件排版利用率93%" + elif purpose in {"实际成本复核", "工程报价"} and batch_qty >= 10: + factor, effective_yield = 0.84, 0.90 + basis = "10至49件工厂小批采购:公开参考价×84%,跨件排版利用率90%" + elif batch_qty >= 10: + factor, effective_yield = 0.90, 0.89 + basis = "批量概算:公开参考价×90%,跨件排版利用率89%" + else: + factor, effective_yield = 0.96, float(base_yield) + basis = "打样/零采概算:公开参考价×96%,沿用单件排版利用率" + return { + "price_factor": factor, + "nesting_yield_rate": max(float(base_yield), effective_yield), + "basis": basis, + } + + def _hardware_counts(self, analysis: dict, selections: dict, *, door_count: int, drawer_count: int) -> dict[str, int]: + """Return physical hardware quantities shared by materials and labor. + + Keeping this in one place prevents the hardware BOM and its installation + route from silently using different door mechanisms or component counts. + """ + panel_piece_count = sum( + int(item.get("quantity") or 1) + for item in (analysis.get("components") or []) + if not self._metal_component(item, selections) + ) + door_mechanism = str(analysis.get("door_mechanism") or "unknown") + joinery_lines = list((analysis.get("production_joinery") or {}).get("hardware_lines") or []) + connector_count = ( + sum(max(0, int(float(item.get("quantity") or 0))) for item in joinery_lines) + if joinery_lines + else max(0, (panel_piece_count - 1) * 2) + ) + return { + "hinge": max(0, int(door_count) * 2) if door_mechanism == "hinged" else 0, + "drawer_slide_pair": max(0, int(drawer_count)), + "handle": max(0, int(door_count) + int(drawer_count)), + "connector": connector_count, + "sliding_track_set": max(1, math.ceil(int(door_count) / 2)) + if door_mechanism == "sliding" and int(door_count) > 0 + else 0, + } + + def _hardware_costing(self, analysis: dict, selections: dict, *, door_count: int, drawer_count: int) -> dict: + """Build a component-level generic hardware estimate. + + This deliberately replaces the old opaque door/drawer reserve and does + not consult company product costs or same-SKU historical BOMs. + """ + grade = str(selections.get("hardware_grade") or "basic") + if grade not in self.HARDWARE_GRADE_FACTORS: + grade = "basic" + factor = float(self.HARDWARE_GRADE_FACTORS[grade]) + panel_piece_count = sum( + int(item.get("quantity") or 1) + for item in (analysis.get("components") or []) + if not self._metal_component(item, selections) + ) + door_mechanism = str(analysis.get("door_mechanism") or "unknown") + counts = self._hardware_counts( + analysis, selections, door_count=door_count, drawer_count=drawer_count + ) + labels = { + "hinge": ("柜门铰链", "个"), + "drawer_slide_pair": ("抽屉导轨", "副"), + "handle": ("门抽拉手", "个"), + "connector": ("螺丝/偏心件等基础连接件", "个"), + "sliding_track_set": ("移门上下轨道与滑轮套件", "套"), + } + lines: list[dict] = [] + for code, quantity in counts.items(): + if code == "connector" and (analysis.get("production_joinery") or {}).get("hardware_lines"): + continue + if quantity <= 0: + continue + unit_price = float(self.HARDWARE_UNIT_PRICES[code]) * factor + lines.append( + { + "name": labels[code][0], + "unit": labels[code][1], + "quantity": float(quantity), + "unit_price": unit_price, + "cost": float(quantity) * unit_price, + "source": f"{self.HARDWARE_GRADE_LABELS[grade]}公司BOM校准部件级价格库", + "evidence": f"识别到{door_count}扇{'平开门' if door_mechanism == 'hinged' else '推拉/其他门'}、{drawer_count}个完整抽屉、{panel_piece_count}件非金属结构件", + } + ) + for joinery_line in (analysis.get("production_joinery") or {}).get("hardware_lines") or []: + quantity = max(0, int(float(joinery_line.get("quantity") or 0))) + if quantity <= 0: + continue + unit_price = max(0.0, float(joinery_line.get("unit_price") or 0.0)) + lines.append( + { + "hardware_code": str(joinery_line.get("hardware_code") or "production_joinery"), + "hardware_template_id": str(joinery_line.get("hardware_template_id") or ""), + "name": str(joinery_line.get("name") or "生产连接件"), + "specification": str(joinery_line.get("specification") or ""), + "unit": str(joinery_line.get("unit") or "个"), + "quantity": float(quantity), + "unit_price": unit_price, + "cost": float(quantity) * unit_price, + "source": str(joinery_line.get("source") or "CAD生产连接方案"), + "evidence": str(joinery_line.get("evidence") or "CAD孔位反推"), + } + ) + cost = sum(float(line["cost"]) for line in lines) + return { + "formula_cost": cost, + "historical_reference": 0.0, + "historical_weight": 0.0, + "cost": cost, + "confidence": "component_formula", + "grade": grade, + "grade_label": self.HARDWARE_GRADE_LABELS[grade], + "recognized_hardware": list(analysis.get("recognized_hardware") or []), + "lines": lines, + "door_mechanism": door_mechanism, + "note": "五金按门型、完整抽屉和CAD生产孔位逐项估算;柜体螺钉、木榫及抽屉螺钉的规格和数量由同一连接方案反推,推拉门铰链固定为0;五金材料与孔槽/安装人工分列且不重复", + } + + def _edge_price_for(self, edge_code: str) -> dict: + version, prices = self._active_edge_prices() + code = str(edge_code or "PVC_1").upper() + if code not in prices: + code = "PVC_1" + result = dict(prices[code]) + if "胶" not in str(result.get("edge_name") or ""): + result["edge_name"] = f"{result.get('edge_name') or code}(含封边胶水)" + result["includes_adhesive"] = True + result["version"] = version + return result + + def _price_for(self, requested: str) -> dict: + requested_key = re.sub(r"[\s()()·/\\_-]+", "", str(requested or "")).lower() + prices = self._prices() + exact = [p for p in prices if re.sub(r"[\s()()·/\\_-]+", "", p["species_name"]).lower() == requested_key] + if exact: + return exact[0] + partial = [p for p in prices if requested_key and requested_key in re.sub(r"[\s()()·/\\_-]+", "", p["species_name"]).lower()] + if partial: + return partial[0] + aliases = { + "白橡木": "白橡(北美FAS)", "白橡": "白橡(北美FAS)", + "黑胡桃木": "黑胡桃(北美FAS)", "黑胡桃": "黑胡桃(北美FAS)", + "桐木": "桐木", "橡胶木": "橡胶木(国产)", "松木": "松木(樟子松)", + } + alias = aliases.get(requested_key) + if alias: + return next(p for p in prices if p["species_name"] == alias) + raise SolidWoodCostError(f"价格库中没有找到材种:{requested}") + + def _new_project(self, context_key: str) -> dict: + project_id = f"wood_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}" + now = self._now() + with self._connect() as conn: + conn.execute( + "INSERT INTO projects(project_id,context_key,stage,display_text,created_at,updated_at) VALUES(?,?,?,?,?,?)", + (project_id, context_key, "waiting_assets", "等待产品多视图和详细尺寸图", now, now), + ) + return self.get_project(project_id) or {} + + def get_project(self, project_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute("SELECT * FROM projects WHERE project_id=?", (project_id,)).fetchone() + if not row: + return None + value = dict(row) + for key in ("analysis_json", "selections_json", "result_json"): + try: + value[key[:-5]] = json.loads(value.pop(key) or "{}") + except json.JSONDecodeError: + value[key[:-5]] = {} + return value + + def latest_project(self, context_key: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT project_id FROM projects WHERE context_key=? ORDER BY updated_at DESC LIMIT 1", + (context_key,), + ).fetchone() + return self.get_project(str(row["project_id"])) if row else None + + def _update_project(self, project_id: str, stage: str, display_text: str, *, analysis=None, selections=None, result=None, error="") -> dict: + current = self.get_project(project_id) or {} + with self._connect() as conn: + conn.execute( + """ + UPDATE projects SET stage=?,display_text=?,product_name=?,analysis_json=?,selections_json=?, + result_json=?,error=?,updated_at=? WHERE project_id=? + """, + ( + stage, + display_text, + str((analysis if analysis is not None else current.get("analysis") or {}).get("product_name") or current.get("product_name") or ""), + json.dumps(analysis if analysis is not None else current.get("analysis") or {}, ensure_ascii=False), + json.dumps(selections if selections is not None else current.get("selections") or {}, ensure_ascii=False), + json.dumps(result if result is not None else current.get("result") or {}, ensure_ascii=False), + str(error or "")[:4000], + self._now(), + project_id, + ), + ) + return self.get_project(project_id) or {} + + @staticmethod + def _asset_hash(item: dict) -> str: + # The same annotated front image may legitimately be submitted once as + # the front view and once as the dimension drawing. Include the user- + # visible filename so exact-content files with distinct declared roles + # can coexist, while an accidental resubmission of the same file/name is + # still idempotently ignored. + filename = pathlib.Path(str(item.get("filename") or "upload.jpg")).name.strip().lower() + return hashlib.sha256( + f"{item.get('kind')}:{item.get('value')}:{filename}".encode("utf-8") + ).hexdigest() + + def _infer_role(self, filename: str, used: set[str]) -> str: + key = str(filename or "").lower() + hints = ( + ("dimension", ("dimension", "drawing", "尺寸", "工程图", "标注")), + ("open_or_third", ("open", "door", "开门", "内部", "third", "45")), + ("side", ("side", "侧面", "侧视")), + ("front", ("front", "正面", "正视")), + ) + for role, words in hints: + if role not in used and any(word in key for word in words): + return role + return next((role for role in self.ROLES if role not in used), "open_or_third") + + def _store_assets(self, project_id: str, image_inputs: list[dict]) -> None: + with self._connect() as conn: + used = { + str(row[0]) + for row in conn.execute( + "SELECT role FROM project_assets WHERE project_id=? AND replaced_by_asset_id IS NULL AND validation_status<>'rejected'", + (project_id,), + ) + } + for item in image_inputs: + if not isinstance(item, dict): + continue + kind = str(item.get("kind") or "") + value = str(item.get("value") or "").strip() + if kind not in {"image_url", "image_base64"} or not value: + continue + filename = str(item.get("filename") or "upload.jpg")[:180] + digest = self._asset_hash(item) + role = self._infer_role(filename, used) + try: + cursor = conn.execute( + """ + INSERT INTO project_assets( + project_id,role,filename,kind,value,sha256,validation_status,validation_json,created_at + ) VALUES(?,?,?,?,?,?,?,? ,?) + """, + (project_id, role, filename, kind, value, digest, "pending", "{}", self._now()), + ) + new_asset_id = int(cursor.lastrowid) + old = conn.execute( + """ + SELECT asset_id FROM project_assets + WHERE project_id=? AND role=? AND asset_id<>? AND validation_status='rejected' + AND replaced_by_asset_id IS NULL + ORDER BY asset_id DESC LIMIT 1 + """, + (project_id, role, new_asset_id), + ).fetchone() + if old: + conn.execute( + "UPDATE project_assets SET replaced_by_asset_id=? WHERE asset_id=?", + (new_asset_id, int(old[0])), + ) + used.add(role) + except sqlite3.IntegrityError: + continue + + def _assets(self, project_id: str, *, include_superseded: bool = False) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT asset_id,role,filename,kind,value,sha256,validation_status,validation_json,replaced_by_asset_id + FROM project_assets + WHERE project_id=? AND (?=1 OR replaced_by_asset_id IS NULL) + ORDER BY asset_id + """, + (project_id, 1 if include_superseded else 0), + ).fetchall() + assets = [] + for row in rows: + item = dict(row) + try: + item["validation"] = json.loads(item.pop("validation_json") or "{}") + except json.JSONDecodeError: + item["validation"] = {} + assets.append(item) + return assets + + def _assets_answer(self, project: dict) -> str: + assets = self._assets(project["project_id"]) + by_role = {item["role"]: item for item in assets if item.get("validation_status") != "rejected"} + rejected = {item["role"]: item for item in assets if item.get("validation_status") == "rejected"} + lines = [] + for role in self.ROLES: + item = by_role.get(role) + if item and item.get("validation_status") == "accepted": + state = "已通过" + elif item: + state = "已收到,待检查" + elif role in rejected: + issues = ";".join(str(x) for x in (rejected[role].get("validation") or {}).get("issues") or []) + state = f"需单张补传:{issues or '图片不符合要求'}" + else: + state = "待上传" + lines.append(f"| {self.ROLE_LABELS[role]} | {state} |") + return ( + f"### {self.DISPLAY_NAME}\n\n" + "> **当前步骤:** 上传产品图片和详细尺寸图。已收到的图片已保存,不需要重复上传。\n\n" + "| 图片要求 | 状态 |\n| --- | --- |\n" + "\n".join(lines) + + "\n\n只需把“待上传”或“需单张补传”的那一张作为聊天附件发送;已通过的图片不会再次索要。" + ) + + @staticmethod + def _asset_validation_content(assets: list[dict]) -> list[dict]: + content: list[dict] = [] + for index, item in enumerate(assets, 1): + content.append( + { + "type": "text", + "text": f"图片编号{index},asset_id={item['asset_id']},文件名={item['filename']}。", + } + ) + value = str(item.get("value") or "") + if item.get("kind") == "image_url" and re.match(r"^https?://", value, re.I): + content.append({"type": "image_url", "image_url": {"url": value}}) + elif item.get("kind") == "image_base64" and value.startswith("data:image/"): + content.append({"type": "image_url", "image_url": {"url": value}}) + content.append( + { + "type": "text", + "text": ( + "你是家具产品图资料质检员。只返回JSON对象。逐张判断是否属于同一产品,并从" + "front、side、open_or_third、dimension中选择唯一角色。检查主体是否完整、清晰、无遮挡;" + "dimension图至少要有可辨识的整体宽和整体高才可usable;深度、内部长度或抽屉尺寸未标注时仍可作为部分合格尺寸图," + "但必须如实返回覆盖状态和issues,不得将局部尺寸推测为已标注尺寸;" + "不得把门宽或其他局部尺寸当成整体深度;有柜门时open_or_third优先为开门图,无柜门可为第三视角。" + "side只要画面明确展示产品前后深度或完整侧面轮廓即可通过;尺寸数值可由dimension图提供," + "不得因为side图片自身没有标注深度、整体宽度或高度而拒绝清晰的侧面轮廓;" + "正面投影即使文件名含‘侧视’也不能判为side。" + "open_or_third必须比正视图额外展示内部、开门状态、侧后关系或明确空间透视,不能用重复正视图凑数。" + "带尺寸标注的轴测/透视图仍然可以作为open_or_third;若同一批有两张带标注的轴测图," + "应把尺寸最完整的一张分给dimension,另一张只要清楚展示三维深度或侧后关系就分给open_or_third," + "不能仅因两张都有尺寸文字而把它们都判成dimension。" + "不能凭文件名代替画面判断。同一张画面如同时完整展示产品正面并有可读的整体宽高标注," + "允许两个同内容副本分别承担front和dimension,不应当作无效重复。返回:" + '{"same_product":true,"has_doors":true,"assets":[{"asset_id":1,' + '"detected_role":"front","usable":true,"issues":[],"confidence":0.95,' + '"view_evidence":"完整正面与两个抽屉均可见",' + '"dimension_coverage":{"overall_width":true,"overall_depth":false,"overall_height":true,' + '"internal_widths":false,"drawer_box":false,"door_leaf":true,"unit":"cm"}}]}。' + "角色重复时选择画面最符合的一张,其余usable=false并说明缺少哪个角色。" + ), + } + ) + return content + + def _request_model_json( + self, + body: dict, + failure_label: str, + *, + decoder: Callable[[str], dict] | None = None, + max_attempts: int = 3, + timeout_seconds: int | None = None, + ) -> dict: + """Call the multimodal model with bounded automatic JSON retries. + + Some compatible model gateways occasionally return a nearly valid JSON + object even when ``response_format`` is requested. Retrying inside the + same turn keeps accepted uploads intact and avoids asking the user to + resend the same images. + """ + last_error: Exception | None = None + bounded_attempts = max(1, min(3, int(max_attempts))) + request_timeout = max(30, int(timeout_seconds or self.timeout)) + for attempt in range(bounded_attempts): + attempt_body = json.loads(json.dumps(body, ensure_ascii=False)) + if attempt: + content = attempt_body["messages"][0].get("content") + retry_text = ( + f"这是第{attempt + 1}次自动格式重试。只返回一个紧凑、完整、可被json.loads" + "直接解析的JSON对象;所有属性名和字符串都使用英文双引号,不要注释、Markdown、" + "思考过程或额外文字,输出前检查逗号与括号。" + ) + if isinstance(content, list): + content.append({"type": "text", "text": retry_text}) + else: + attempt_body["messages"][0]["content"] = str(content or "") + "\n" + retry_text + request = urllib.request.Request( + f"{self.api_base}/chat/completions", + data=json.dumps(attempt_body, ensure_ascii=False).encode("utf-8"), + headers={"Authorization": f"Bearer {self._api_key()}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=request_timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + self._last_model_response_metadata = { + "requested_model": attempt_body.get("model"), + "returned_model": payload.get("model"), + "finish_reason": (payload.get("choices") or [{}])[0].get("finish_reason"), + "usage": payload.get("usage") or {}, + } + raw = payload["choices"][0]["message"]["content"] + if isinstance(raw, list): + raw = "".join(str(item.get("text") or "") for item in raw if isinstance(item, dict)) + return (decoder or self._decode_model_json)(str(raw or "")) + except urllib.error.HTTPError as exc: + # Authentication failures cannot be repaired by asking the + # model for JSON again. Preserve a stable, non-secret error + # category so the conversation runtime reports configuration + # failure instead of pretending that all source dimensions + # were missing from an otherwise valid image. + if exc.code in {401, 403}: + raise SolidWoodCostError( + f"{failure_label}:读图模型鉴权失败(HTTP {exc.code})" + ) from exc + last_error = exc + except ( + urllib.error.URLError, + TimeoutError, + KeyError, + IndexError, + TypeError, + ValueError, + json.JSONDecodeError, + SolidWoodCostError, + ) as exc: + last_error = exc + raise SolidWoodCostError( + f"{failure_label}:模型连续{bounded_attempts}次未返回可解析JSON({last_error})" + ) from last_error + + def _adjudicate_annotated_third_view(self, candidates: list[dict]) -> dict: + """Use pixels, not filenames, to split two annotated perspective views. + + Furniture drawings often contain dimensions on every axonometric view. + A broad role classifier may therefore label both as ``dimension`` and + falsely report the third view missing. This narrow second look asks + only whether one image proves spatial depth/side relations. + """ + content: list[dict] = [ + { + "type": "text", + "text": ( + "只判断下面带尺寸标注的家具图片中,哪一张能同时作为第三视角图。" + "第三视角必须从像素中清楚看到三维透视、前后深度、侧后关系或内部结构," + "不能只凭文件名;纯平面正视图不合格。只返回JSON:" + '{"qualifies":true,"asset_id":1,"confidence":0.95,' + '"evidence":"轴测图同时显示正面、侧板和前后深度"}。' + ), + } + ] + known_ids: set[int] = set() + for item in candidates[:4]: + asset_id = int(self._float(item.get("asset_id"), 0) or 0) + review_url = self._asset_review_data_url(item) + if not asset_id or not review_url: + continue + known_ids.add(asset_id) + content.append({"type": "text", "text": f"候选图 asset_id={asset_id}"}) + content.append({"type": "image_url", "image_url": {"url": review_url}}) + if len(known_ids) < 2: + return {} + body = { + "model": self.model, + "messages": [{"role": "user", "content": content}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": 512, + } + try: + result = self._request_model_json( + body, + "带尺寸第三视角复核失败", + max_attempts=2, + timeout_seconds=min(self.timeout, 120), + ) + except SolidWoodCostError: + return {} + asset_id = int(self._float(result.get("asset_id"), 0) or 0) + confidence = min(1.0, max(0.0, self._float(result.get("confidence"), 0.0) or 0.0)) + if not bool(result.get("qualifies")) or asset_id not in known_ids or confidence < 0.85: + return {} + return { + "asset_id": asset_id, + "confidence": confidence, + "evidence": str(result.get("evidence") or "")[:240], + } + + def _validate_assets_multimodal(self, project_id: str, assets: list[dict], message: str = "") -> dict: + body = { + "model": self.model, + "messages": [{"role": "user", "content": self._asset_validation_content(assets)}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": 4096, + } + result = self._request_model_json(body, "图片资料合格性检查失败") + known_ids = {int(item["asset_id"]) for item in assets} + previous_by_id = {int(item["asset_id"]): item for item in assets} + accepted_dimension_has_depth = any( + item.get("validation_status") == "accepted" + and str(item.get("role") or "") == "dimension" + and bool(((item.get("validation") or {}).get("dimension_coverage") or {}).get("overall_depth")) + for item in assets + ) + accepted_roles: dict[str, tuple[int, float]] = {} + explicit_overall_dimensions = self._explicit_user_overall_dimensions(message) + for asset in assets: + role = str(asset.get("role") or "") + if asset.get("validation_status") != "accepted" or role not in self.ROLES: + continue + previous_validation = asset.get("validation") or {} + confidence = min( + 1.0, + max(0.60, self._float(previous_validation.get("confidence"), 0.80) or 0.80), + ) + accepted_roles[role] = (int(asset["asset_id"]), confidence) + normalized: list[dict] = [] + same_product = bool(result.get("same_product", True)) + for raw_item in result.get("assets") or []: + if not isinstance(raw_item, dict): + continue + asset_id = int(self._float(raw_item.get("asset_id"), 0) or 0) + role = str(raw_item.get("detected_role") or "") + previous = previous_by_id.get(asset_id) or {} + was_accepted = previous.get("validation_status") == "accepted" and str(previous.get("role") or "") in self.ROLES + confidence_default = 0.75 if raw_item.get("usable") else 0.0 + confidence = min(1.0, max(0.0, self._float(raw_item.get("confidence"), confidence_default) or confidence_default)) + issues = [str(x)[:160] for x in (raw_item.get("issues") or []) if str(x).strip()][:5] + if asset_id not in known_ids: + continue + if was_accepted: + # 已通过图片是会话检查点。补传单张时,模型不得把历史合格图片反向判废, + # 也不得因为新图不是同一产品而要求用户把全部图片重传。 + role = str(previous.get("role") or role) + confidence = max(confidence, float(accepted_roles.get(role, (asset_id, 0.80))[1])) + usable = True + issues = [] + else: + usable = bool(raw_item.get("usable")) and same_product and role in self.ROLES and confidence >= 0.75 + if not same_product and any( + item.get("validation_status") == "accepted" for item in assets + ): + issues = ["与已通过图片不是同一产品"] + # A side-view photo proves the silhouette; it does not have to repeat + # dimension labels already accepted on the dedicated dimension sheet. + # Some multimodal responses correctly describe a visible side outline + # but then contradict themselves by rejecting it only for missing labels. + # Recover only that narrow, evidence-backed case. Blurry, cropped, + # occluded, wrong-product, or genuinely front-only images stay rejected. + declared_role = str(previous.get("role") or "") + evidence = str(raw_item.get("view_evidence") or "") + dimension_coverage = dict(raw_item.get("dimension_coverage") or {}) + positive_side = bool( + re.search(r"(?:展示|可见|呈现).{0,12}(?:侧面轮廓|侧板|侧面视图)", evidence) + ) + negative_side = bool( + re.search(r"(?:未|没有|无法|缺少).{0,16}(?:侧面轮廓|侧板|侧面视图)", evidence) + ) + hard_reject = any( + re.search(r"模糊|裁切|遮挡|不完整|不是同一|非同一|无法辨识", issue) + for issue in issues + ) + if ( + not usable + and same_product + and role == "side" + and declared_role == "side" + and confidence >= 0.75 + and accepted_dimension_has_depth + and positive_side + and not negative_side + and not hard_reject + ): + usable = True + issues = [] + # An annotated three-quarter/side image is still the side + # view when it visibly establishes product depth. Models + # sometimes relabel it as `dimension` solely because arrows + # are present, which then falsely makes the real detailed + # drawing look like a duplicate. + if ( + same_product + and declared_role == "side" + and role == "dimension" + and bool(raw_item.get("usable")) + and confidence >= 0.75 + and bool(dimension_coverage.get("overall_depth")) + and not hard_reject + ): + role = "side" + usable = True + issues = [] + # If the user explicitly supplied the complete product + # envelope in the same turn, a detailed drawing remains useful + # when it contributes multiple internal/drawer dimensions. It + # must not be rejected only because it omits one repeated + # overall label already confirmed in text. + useful_detail_count = sum( + bool(dimension_coverage.get(key)) + for key in ("overall_depth", "overall_height", "internal_widths", "drawer_box", "door_leaf") + ) + if ( + not usable + and same_product + and declared_role == "dimension" + and role == "dimension" + and explicit_overall_dimensions + and confidence >= 0.75 + and useful_detail_count >= 2 + and not hard_reject + ): + usable = True + issues = [ + issue for issue in issues + if not re.search(r"整体(?:宽度|高度|深度).*(?:未标注|缺少)", issue) + ] + if usable: + current = accepted_roles.get(role) + if current and current[0] == asset_id: + pass + elif current and previous_by_id.get(current[0], {}).get("validation_status") == "accepted": + usable = False + issues.append(f"与已通过的{self.ROLE_LABELS[role]}重复") + elif current and current[1] >= confidence: + usable = False + issues.append(f"与已通过的{self.ROLE_LABELS[role]}重复") + else: + if current: + for old in normalized: + if old["asset_id"] == current[0]: + old["usable"] = False + old["issues"].append(f"由更清晰的{self.ROLE_LABELS[role]}替代") + accepted_roles[role] = (asset_id, confidence) + normalized.append( + { + "asset_id": asset_id, + "detected_role": role, + "usable": usable, + "issues": issues, + "confidence": confidence, + "view_evidence": str(raw_item.get("view_evidence") or "")[:240], + "dimension_coverage": dict(raw_item.get("dimension_coverage") or {}) if role == "dimension" else {}, + } + ) + returned_ids = {item["asset_id"] for item in normalized} + for asset in assets: + if int(asset["asset_id"]) not in returned_ids: + was_accepted = asset.get("validation_status") == "accepted" and str(asset.get("role") or "") in self.ROLES + normalized.append( + {"asset_id": int(asset["asset_id"]), "detected_role": str(asset.get("role") or ""), "usable": was_accepted, + "issues": [] if was_accepted else ["模型未能判断该图片角色"], "confidence": 0.80 if was_accepted else 0.0, + "view_evidence": str((asset.get("validation") or {}).get("view_evidence") or "")[:240], + "dimension_coverage": dict((asset.get("validation") or {}).get("dimension_coverage") or {})} + ) + valid_roles_before_adjudication = { + item["detected_role"] for item in normalized if item["usable"] + } + dimension_candidates = [ + previous_by_id[item["asset_id"]] + for item in normalized + if item.get("detected_role") == "dimension" + and float(item.get("confidence") or 0.0) >= 0.75 + and item["asset_id"] in previous_by_id + and not any( + re.search(r"模糊|裁切|遮挡|不完整|不是同一|非同一|无法辨识", str(issue)) + for issue in (item.get("issues") or []) + ) + ] + if ( + same_product + and "open_or_third" not in valid_roles_before_adjudication + and len(dimension_candidates) >= 2 + ): + adjudication = self._adjudicate_annotated_third_view(dimension_candidates) + chosen_id = int(adjudication.get("asset_id") or 0) + if chosen_id: + chosen = next(item for item in normalized if item["asset_id"] == chosen_id) + chosen.update( + { + "detected_role": "open_or_third", + "usable": True, + "issues": [], + "confidence": max(float(chosen.get("confidence") or 0.0), float(adjudication["confidence"])), + "view_evidence": adjudication.get("evidence") or chosen.get("view_evidence") or "", + "dimension_coverage": {}, + } + ) + remaining_dimensions = [ + item for item in normalized + if item["asset_id"] != chosen_id and item.get("detected_role") == "dimension" + ] + if remaining_dimensions: + dimension_item = max( + remaining_dimensions, + key=lambda item: float(item.get("confidence") or 0.0), + ) + dimension_item["usable"] = True + dimension_item["issues"] = [ + issue for issue in (dimension_item.get("issues") or []) + if "重复" not in str(issue) and "替代" not in str(issue) + ] + with self._connect() as conn: + for item in normalized: + status = "accepted" if item["usable"] else "rejected" + conn.execute( + "UPDATE project_assets SET role=?,validation_status=?,validation_json=? WHERE asset_id=?", + ( + item["detected_role"] if item["detected_role"] in self.ROLES else "unclassified", + status, + json.dumps(item, ensure_ascii=False), + item["asset_id"], + ), + ) + valid_roles = {item["detected_role"] for item in normalized if item["usable"]} + missing_roles = [role for role in self.ROLES if role not in valid_roles] + return { + "ok": not missing_roles, + "same_product": same_product, + "has_doors": bool(result.get("has_doors")), + "assets": normalized, + "missing_roles": missing_roles, + } + + def _api_key(self) -> str: + direct = os.environ.get("DASHSCOPE_API_KEY", "").strip() + if direct: + return direct + path = os.environ.get("DASHSCOPE_API_KEY_FILE", "").strip() + if path: + try: + return pathlib.Path(path).read_text(encoding="utf-8").strip() + except OSError as exc: + raise SolidWoodCostError("家具读图模型密钥文件不可读") from exc + raise SolidWoodCostError("家具读图模型尚未配置密钥") + + @staticmethod + def _vision_content(assets: list[dict], message: str) -> list[dict]: + content: list[dict] = [] + role_order = {role: index for index, role in enumerate(SolidWoodCostEngine.ROLES)} + role_labels = SolidWoodCostEngine.ROLE_LABELS + for item in sorted(assets, key=lambda value: role_order.get(str(value.get("role") or ""), 99)): + value = str(item.get("value") or "") + role = str(item.get("role") or "") + content.append( + { + "type": "text", + "text": ( + f"以下图片的系统候选角色是:{role_labels.get(role, '产品参考图')}。" + "候选角色不是事实,必须根据画面自行复核;如画面不符合该视角,不能顺着标签推断。" + ), + } + ) + if item.get("kind") == "image_url" and re.match(r"^https?://", value, re.I): + content.append({"type": "image_url", "image_url": {"url": value}}) + elif item.get("kind") == "image_base64" and value.startswith("data:image/"): + content.append({"type": "image_url", "image_url": {"url": value}}) + content.append( + { + "type": "text", + "text": ( + "你是家具拆单、板式与金属结构、参数化CAD工程师。图片依次包含正视图、侧视图、开门或第三视角、详细尺寸图。" + "严格优先使用尺寸图标注;把产品拆成可独立加工的板件、木件和金属件,不能把整个柜体作为一个零件。" + "返回单个JSON对象,不要Markdown。结构:" + '{"product_name":"","product_type":"","overall_dimensions_mm":{"x":宽,"y":深,"z":高},' + '"dimension_evidence":{"x":{"value":0,"source":"","confidence":0},' + '"y":{"value":0,"source":"","confidence":0},' + '"z":{"value":0,"source":"","confidence":0}},' + '"has_doors":true,"door_mechanism":"sliding/hinged/none/unknown","door_count":0,"drawer_count":0,' + '"drawer_layout":"horizontal/vertical/mixed/unknown","drawer_layout_confidence":0,"drawer_layout_evidence":"",' + '"has_back_panel":null,"back_panel_confidence":0,"back_panel_evidence":"",' + '"side_panel_segmentation":{"segments_per_side":0,"segment_heights_mm":[], ' + '"segment_depths_mm":[],"evidence":"","confidence":0},' + '"door_dimensions_mm":{"leaf_widths":[],"leaf_height":0,' + '"source":"","confidence":0},' + '"components":[{"part_id":"TOP-001","name":"顶板","quantity":1,' + '"shape":"box","dimensions_mm":{"x":宽,"y":深,"z":厚},' + '"dimension_evidence":{"x":{"source":"尺寸图顶板宽","confidence":0.95},' + '"y":{"source":"尺寸图顶板深","confidence":0.95},' + '"z":{"source":"尺寸图板厚","confidence":0.95}},' + '"origins_mm":[{"x":0,"y":0,"z":0}],"material_group":"主体结构/背板/抽屉底板/五金",' + '"component_role":"","assembly_group":"carcass/drawer/other","structure_evidence":"",' + '"recommended_species":"白橡木","coating_area_m2":0.0,' + '"machining":{"holes":0,"slots":0,"mortise_tenon":0,"edge_profile_m":0.0,' + '"carving_area_m2":0.0,"carving_path_m":0.0,"evidence":"","confidence":0.0},"confidence":0.0}],' + '"uncertainties":[""],"assumptions":[""]}。' + "坐标原点为产品左前下角;每个数量尽量给对应origins_mm。整体宽、深、高必须分别有证据," + "上面的零值、空数组只是字段定义,不是本产品答案。先沿标注线两端判断它量的是哪个方向、" + "整体还是局部,再读数和单位。竖向模块高度不是产品宽度,部件内宽不是成品外宽。" + "能从其他原图验证的疑点先交叉检查,不要把可见的层板数量、抽屉排列直接丢给用户。" + "部件坐标必须始终遵守产品坐标:X=左右宽度、Y=前后深度、Z=上下高度,不能因板件朝向改变轴含义。" + "顶板/底板/层板写{x:宽,y:深,z:厚};侧板写{x:厚,y:深,z:高};" + "门板/背板写{x:宽,y:厚,z:高};腿写{x:截面宽,y:截面深,z:腿高}。" + "尺寸图若有COMPONENT SCHEDULE、BOM或零件表,必须逐行原样读取名称、数量和尺寸,括号中的数量优先于视觉猜测;" + "例如Side panel 20×400×720应写x=20,y=400,z=720,Shelf 1160×380×20应写x=1160,y=380,z=20。" + "不得把门宽、抽屉宽、局部高度或包装尺寸填成整体深度;某轴没有标注时value写0、source写未标注。" + "仅输出有图像证据或尺寸约束的结构," + "不确定内容写入uncertainties。五金可列出,但shape写hardware。" + "components最多30项;同名、同尺寸、同材质组的零件必须合并为一项并用quantity表示数量," + "不要重复展开相同零件。部件尺寸必须做装配几何自检:桌面/顶板才可以接近产品整体宽×深;" + "侧板应主要对应深×高,门板/前挡板应主要对应宽×高。不得把产品整体宽×深复制给侧板、前挡板等多个不同部件," + "也不得仅凭整体尺寸臆造所有内部板件尺寸;看不清的内部尺寸写入uncertainties。" + "对于桌面加单侧抽屉柜/活动柜结构,先区分桌面、金属脚架、横撑和局部柜体;局部柜体的宽高深不得套用整张桌面的宽度," + "未看见整幅侧板或整幅背板时不得补成覆盖整件产品的大板。背板、抽屉底板通常是独立薄板,厚度看不清时必须写入不确定项," + "不得擅自使用主体板厚。每个部件的x/y/z都必须给出dimension_evidence;" + "只有尺寸图文字标注、清晰比例约束或用户明确提供才可高置信。根据经验、外观比例、常规减板厚、" + "总尺寸反推的数值必须在source写‘估算’或‘反推’且confidence不得高于0.55,不能伪装成尺寸图标注。" + "图片可见的抽屉数量、柜门数量、拉手、铰链、导轨和连接件应按shape=hardware单独列出。" + "识别到抽屉时,每个抽屉必须完整核对抽面、左帮板、右帮板、后帮板和抽屉底板;抽屉内宽、箱体深度和帮板高没有直接标注时," + "先根据抽面位置识别上下叠放vertical、左右并排horizontal或混排mixed,不得仅因数量大于1就把抽面宽度均分。" + "柜体中横跨多个抽屉的承托横隔板属于carcass,component_role=drawer_support;" + "随单个抽屉移动的底板才属于drawer,component_role=drawer_bottom。不得混算或相互替代。" + "必须写低置信的估算证据,不得盲目当成已确认尺寸;看不清可给低置信度,但不得只输出抽面而遗漏抽屉箱体。" + "区分推拉门/移门与平开门:看到上下轨道、门片重叠或水平移动时door_mechanism必须为sliding," + "推拉门绝不能输出铰链。同一门片不得同时以前挡板和柜门重复输出。" + "尺寸图标注了门片宽高时必须单独写入door_dimensions_mm," + "只要has_doors=true,door_count不得为0;若门片表写(2)或leaf_widths有2项,door_count必须为2。" + "正视图与开门图显示的门片必须命名为柜门/门板,不得改名为前挡板;平开门还要核对铰链和拉手数量。" + "柜门高度不得直接等于带顶部抽屉柜的整体高度。" + "如果侧板存在模块接缝、上下分段或不同深度,必须逐图识别并填写side_panel_segmentation;" + "segments_per_side是每一侧的板件段数,不是层板数量;segment_heights_mm之和应与侧板有效高度一致," + "segment_heights_mm和segment_depths_mm都必须按产品从下到上填写。" + "没有清晰接缝证据时segments_per_side写1且confidence不高于0.55。" + "必须结合正面、侧面、第三视角判断是否有背板:只有画面确实显示背部封闭或明确无遮挡的开放后沿时," + "has_back_panel才可给true/false并给出证据;无法看见背部时写null、confidence不高于0.55,不能把看不见当作没有。" + "混合家具必须把钢架、钢腿、横撑等作为独立金属部件输出,不得只输出一组桌腿代替整个金属框架。" + "输出前检查JSON逗号、引号、括号、部件数量和几何关系完整。" + f"\n用户说明:{message.strip()}" + ), + } + ) + return content + + @staticmethod + def _display_product_name(analysis: dict) -> str: + raw = str(analysis.get("product_name") or "").strip() + if re.search(r"[\u4e00-\u9fff]", raw): + return raw[:80] + key = f"{raw} {analysis.get('product_type') or ''}".lower() + aliases = ( + (("sideboard", "credenza"), "实木边柜"), + (("wardrobe",), "实木衣柜"), + (("cabinet", "cupboard"), "实木柜"), + (("dresser", "chest of drawers"), "实木斗柜"), + (("table", "desk"), "实木桌"), + (("chair", "stool"), "实木座椅"), + (("bed",), "实木床"), + (("shelf", "bookcase"), "实木置物架"), + ) + for words, label in aliases: + if any(word in key for word in words): + return label + return "实木家具" + + @staticmethod + def _json_fragment(text: str, start: int) -> str: + """Return one balanced JSON object/array starting at ``start``.""" + if start < 0 or start >= len(text) or text[start] not in "{[": + return "" + stack: list[str] = [] + in_string = False + escaped = False + pairs = {"}": "{", "]": "["} + for index in range(start, len(text)): + char = text[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char in "{[": + stack.append(char) + elif char in "}]": + if not stack or stack[-1] != pairs[char]: + return "" + stack.pop() + if not stack: + return text[start:index + 1] + return "" + + @classmethod + def _decode_model_json(cls, raw: str) -> dict: + """Decode model JSON and repair conservative missing/trailing commas.""" + text = re.sub(r"^```(?:json)?\s*|\s*```$", "", str(raw or "").strip(), flags=re.S) + start = text.find("{") + fragment = cls._json_fragment(text, start) + candidate = fragment or (text[start:] if start >= 0 else text) + candidate = re.sub(r",\s*([}\]])", r"\1", candidate) + last_error: json.JSONDecodeError | None = None + for _ in range(40): + try: + value = json.loads(candidate) + if not isinstance(value, dict): + raise SolidWoodCostError("家具读图结果不是JSON对象") + return value + except json.JSONDecodeError as exc: + last_error = exc + if not str(exc.msg).startswith("Expecting ',' delimiter"): + break + position = max(0, min(len(candidate), int(exc.pos))) + previous = position - 1 + while previous >= 0 and candidate[previous].isspace(): + previous -= 1 + current = position + while current < len(candidate) and candidate[current].isspace(): + current += 1 + if previous < 0 or current >= len(candidate): + break + previous_char = candidate[previous] + current_char = candidate[current] + value_end = previous_char in '}\"]' or previous_char.isdigit() or previous_char in "eElL" + value_start = current_char in '"{[' or current_char.isdigit() or current_char in "-tfn" + if not (value_end and value_start): + break + candidate = candidate[:current] + "," + candidate[current:] + # Conservative compatibility for Python-style/smart-quote objects and + # bare ASCII property names returned by some OpenAI-compatible relays. + relaxed = candidate.translate(str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'"})) + relaxed = re.sub(r'([\{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)\s*:', r'\1"\2":', relaxed) + try: + value = json.loads(relaxed, strict=False) + if isinstance(value, dict): + return value + except json.JSONDecodeError: + pass + try: + python_literal = re.sub(r"\btrue\b", "True", relaxed, flags=re.I) + python_literal = re.sub(r"\bfalse\b", "False", python_literal, flags=re.I) + python_literal = re.sub(r"\bnull\b", "None", python_literal, flags=re.I) + value = ast.literal_eval(python_literal) + if isinstance(value, dict): + return value + except (SyntaxError, ValueError): + pass + try: + return cls._salvage_model_json(candidate) + except (ValueError, TypeError, json.JSONDecodeError) as salvage_error: + raise last_error or salvage_error + + @classmethod + def _salvage_model_json(cls, text: str) -> dict: + """Recover complete component objects from a malformed outer payload.""" + analysis: dict = { + "product_name": "实木家具", + "product_type": "家具", + "has_doors": False, + "components": [], + "uncertainties": ["视觉模型返回格式已自动修复;关键尺寸请在报价前复核"], + "assumptions": [], + } + for key in ("product_name", "product_type"): + match = re.search(rf'"{key}"\s*:\s*("(?:\\.|[^"\\])*")', text, re.S) + if match: + try: + analysis[key] = json.loads(match.group(1)) + except json.JSONDecodeError: + pass + door_match = re.search(r'"has_doors"\s*:\s*(true|false)', text, re.I) + if door_match: + analysis["has_doors"] = door_match.group(1).lower() == "true" + overall_key = re.search(r'"overall_dimensions_mm"\s*:', text) + if overall_key: + overall_start = text.find("{", overall_key.end()) + overall_fragment = cls._json_fragment(text, overall_start) + if overall_fragment: + try: + analysis["overall_dimensions_mm"] = json.loads(overall_fragment) + except json.JSONDecodeError: + pass + # Keep the axis labels even when the outer model JSON is truncated. + # Without this ledger, a recoverable response can retain all three + # numbers but silently swap product width and depth during salvage. + for object_key in ("dimension_evidence", "side_panel_segmentation"): + object_match = re.search(rf'"{object_key}"\s*:', text) + if not object_match: + continue + object_start = text.find("{", object_match.end()) + object_fragment = cls._json_fragment(text, object_start) + if not object_fragment: + continue + try: + object_value = json.loads(object_fragment) + if isinstance(object_value, dict): + analysis[object_key] = object_value + except json.JSONDecodeError: + pass + components_key = re.search(r'"components"\s*:', text) + if components_key: + array_start = text.find("[", components_key.end()) + array_fragment = cls._json_fragment(text, array_start) + source = array_fragment or text[array_start:] + cursor = 1 if source.startswith("[") else 0 + while cursor < len(source) and len(analysis["components"]) < 40: + object_start = source.find("{", cursor) + if object_start < 0: + break + object_fragment = cls._json_fragment(source, object_start) + if not object_fragment: + cursor = object_start + 1 + continue + try: + item = json.loads(re.sub(r",\s*}", "}", object_fragment)) + except json.JSONDecodeError: + cursor = object_start + len(object_fragment) + continue + if isinstance(item, dict) and "dimensions_mm" in item: + analysis["components"].append(item) + cursor = object_start + len(object_fragment) + if not analysis["components"]: + raise ValueError("没有可恢复的部件JSON") + analysis.setdefault("overall_dimensions_mm", {"x": 1, "y": 1, "z": 1}) + return analysis + + @classmethod + def _adjudicate_source_structure_views(cls, raw: dict, valid_ids: set[int]) -> dict: + """Reconcile per-image structure readings without asking the user. + + A single attractive render is not allowed to erase a seam or quantity + seen in another source view. The result tells the next AI pass what is + corroborated, what conflicts, and what should receive a focused machine + reread. It never converts uncertainty into a user confirmation. + """ + findings = [] + for item in (raw.get("view_findings") or [])[:16]: + if not isinstance(item, dict): + continue + image_id = item.get("image_id") + if image_id not in valid_ids: + continue + confidence = min(1.0, max(0.0, cls._float(item.get("confidence"), 0) or 0)) + counts = item.get("visible_counts") if isinstance(item.get("visible_counts"), dict) else {} + seams = [] + for value in (item.get("side_seam_z_ratios") or [])[:8]: + number = cls._float(value) + if number is not None and 0.01 < number < 0.99: + seams.append(round(number, 4)) + seam_evidence = [] + for row in (item.get("side_seam_evidence") or [])[:8]: + if not isinstance(row, dict): + continue + ratio = cls._float(row.get("ratio_from_bottom")) + description = str(row.get("boundary_description") or "").strip() + if ( + ratio is None or not 0.01 < ratio < 0.99 or not description + or row.get("physical_break_visible") is not True + ): + continue + seam_evidence.append({ + "ratio_from_bottom": round(ratio, 4), + "physical_break_visible": True, + "boundary_description": description[:200], + }) + findings.append({ + "image_id": image_id, + "observed_role": str(item.get("observed_role") or "unknown")[:24], + "complete_product_visible": bool(item.get("complete_product_visible")), + "side_edge_visibility": min(1.0, max(0.0, cls._float(item.get("side_edge_visibility"), 0) or 0)), + "visible_counts": { + key: int(value) for key, value in counts.items() + if key in {"drawers", "doors", "shelves", "dividers"} + and isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 50 + }, + "drawer_layout": str(item.get("drawer_layout") or "unknown"), + "side_segments_per_side": ( + int(item["side_segments_per_side"]) + if isinstance(item.get("side_segments_per_side"), int) + and not isinstance(item.get("side_segments_per_side"), bool) + and 1 <= item["side_segments_per_side"] <= 12 else None + ), + "side_seam_z_ratios": seams, + "side_seam_evidence": seam_evidence, + "confidence": confidence, + "evidence": str(item.get("evidence") or "")[:240], + }) + + accepted = {} + conflicts = [] + machine_followups = [] + + # Quantity/layout can be accepted from a complete, high-confidence + # facade; multiple agreeing views strengthen it. Disagreement is sent + # to another AI crop/review, not immediately to the end user. + drawer_votes = [ + item for item in findings + if item["complete_product_visible"] and item["confidence"] >= 0.78 + and isinstance(item["visible_counts"].get("drawers"), int) + ] + drawer_groups = {} + for item in drawer_votes: + key = (item["visible_counts"]["drawers"], item["drawer_layout"]) + drawer_groups.setdefault(key, []).append(item) + if drawer_groups: + ranked = sorted(drawer_groups.items(), key=lambda pair: (len(pair[1]), max(x["confidence"] for x in pair[1])), reverse=True) + (count, layout), support = ranked[0] + competing = [key for key, values in ranked[1:] if values and key != (count, layout)] + if competing: + conflicts.append({"field": "drawer_count_layout", "candidates": [[count, layout], *[list(x) for x in competing]]}) + machine_followups.append("对抽屉区域做局部放大并重新计数、判断排列") + elif len(support) >= 2 or max(x["confidence"] for x in support) >= 0.92: + accepted["drawers"] = { + "count": count, "layout": layout, + "confidence": min(1.0, max(x["confidence"] for x in support)), + "image_ids": sorted({x["image_id"] for x in support}), + "evidence": ";".join(x["evidence"] for x in support if x["evidence"])[:360], + } + else: + machine_followups.append("独立复读完整正面图的抽屉数量与上下/左右排列") + + # Continuity is especially error-prone: one view saying 'continuous' + # is never enough. A segmented side is accepted only when two source + # views agree on count and approximately agree on seam heights. + raw_seam_votes = [ + item for item in findings + if item["side_segments_per_side"] and item["side_edge_visibility"] >= 0.7 + and item["confidence"] >= 0.72 + ] + seam_votes = [ + item for item in raw_seam_votes + if ( + item["side_segments_per_side"] == 1 + or ( + len(item["side_seam_evidence"]) == item["side_segments_per_side"] - 1 + and len(item["side_seam_z_ratios"]) == item["side_segments_per_side"] - 1 + and all( + abs(item["side_seam_evidence"][index]["ratio_from_bottom"] - ratio) <= 0.02 + for index, ratio in enumerate(item["side_seam_z_ratios"]) + ) + ) + ) + ] + segment_groups = {} + for item in raw_seam_votes: + segment_groups.setdefault(item["side_segments_per_side"], []).append(item) + if len(segment_groups) > 1: + conflicts.append({ + "field": "side_segments_per_side", + "candidates": {str(key): [row["image_id"] for row in values] for key, values in segment_groups.items()}, + }) + machine_followups.append("沿左右外侧边逐段放大复核真实接缝,排除层板横线和颜色边界") + elif segment_groups: + count = next(iter(segment_groups)) + support = [item for item in seam_votes if item["side_segments_per_side"] == count] + ratios = [row["side_seam_z_ratios"] for row in support if len(row["side_seam_z_ratios"]) == count - 1] + ratios_agree = count == 1 or ( + len(ratios) >= 2 and all( + max(values) - min(values) <= 0.06 + for values in zip(*ratios) + ) + ) + if len(support) >= 2 and ratios_agree: + accepted["side_panels"] = { + "segments_per_side": count, + "seam_z_ratios": ([round(sum(values) / len(values), 4) for values in zip(*ratios)] if ratios else []), + "confidence": min(row["confidence"] for row in support), + "image_ids": sorted({row["image_id"] for row in support}), + } + else: + machine_followups.append("用另一原始视角逐条确认侧板物理断缝及接缝高度,不采信仅有段数而没有轮廓断开证据的判断") + return { + "view_findings": findings, + "accepted": accepted, + "conflicts": conflicts, + "machine_followups": list(dict.fromkeys(machine_followups)), + "user_confirmation_required": False, + } + + def _reread_source_structure_conflicts( + self, + assets: list[dict], + adjudication: dict, + ) -> dict: + """Give unresolved visible structure to a narrower AI pass. + + This is deliberately a machine follow-up. It does not ask the user to + count parts that the uploaded pixels can answer and it does not see a + company BOM or a proposed target answer. + """ + tasks = list(adjudication.get("machine_followups") or [])[:6] + if not tasks: + return {"view_findings": []} + content = [{ + "type": "text", + "text": ( + "这是家具原图结构的第二次聚焦复读。上一轮只确定了以下可见冲突/缺口:" + + ";".join(tasks) + + "。必须重新看像素,不得复述上一轮结论,也不得询问用户。逐张图独立输出;" + "数抽屉时按独立抽面边界,判断侧板分段时只沿左右外侧边追踪真实断缝," + "排除层板前沿、颜色边界、阴影和标注线。看不全就降低side_edge_visibility,不能写高置信连续。" + "side_seam_z_ratios必须从成品底部向上递增,逐条列出每一个候选物理断缝;不能只写最显眼的一条。" + "每个ratio必须有同序side_seam_evidence,只有沿侧板外轮廓看到实体终止/重新起始时physical_break_visible=true;" + "层板与侧板相交、颜色变化、尺寸数字或产品顶/底外边界都不是侧板断缝。" + "只返回JSON:{\"view_findings\":[{\"image_id\":1,\"observed_role\":\"front/side/third/dimension/unknown\"," + "\"complete_product_visible\":false,\"side_edge_visibility\":0," + "\"visible_counts\":{\"drawers\":0,\"doors\":0,\"shelves\":0,\"dividers\":0}," + "\"drawer_layout\":\"horizontal/vertical/mixed/unknown\"," + "\"side_segments_per_side\":null,\"side_seam_z_ratios\":[]," + "\"side_seam_evidence\":[{\"ratio_from_bottom\":0.0,\"physical_break_visible\":false,\"boundary_description\":\"\"}]," + "\"confidence\":0,\"evidence\":\"\"}]}。" + ), + }] + for index, asset in enumerate(assets[:8], 1): + content.extend([ + {"type": "text", "text": f"原始图 image_id={index};候选角色={asset.get('role') or '未知'},请自行复核。"}, + {"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}}, + ]) + return self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": content}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": 3072, + "enable_thinking": True, + "thinking_budget": 1536, + "vl_high_resolution_images": True, + }, "原图结构冲突聚焦复读失败", max_attempts=1, timeout_seconds=150) + + def _read_source_structure(self, assets: list[dict]) -> dict: + """Read assembly relationships before asking for numeric CAD geometry. + + This is a source-only visual planning pass: no company BOM, previous + model or numerical fixture is sent. The later CAD review remains the + independent validator; this graph is evidence, not ground truth. + """ + content = [] + valid_ids = set() + for index, asset in enumerate(assets[:8], 1): + source_id = index + valid_ids.add(source_id) + content.extend([ + {"type": "text", "text": f"原始图 image_id={source_id},候选角色={asset.get('role') or '未知'};按画面自行判断视角。"}, + {"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}}, + ]) + content.append({"type": "text", "text": ( + "本轮只识别家具部件及装配关系,不计算尺寸、孔槽或价格。先建立正面、侧面、上下模块关系," + "再逐图核对可见板件;每个抽屉分抽面、左帮、右帮、后帮、底板,不能拿固定柜体横隔板充当移动抽底。" + "侧板分段要沿外沿找真实断开接缝,颜色边界和搁板横线不等于侧板断开;同一种颜色也可能由上下模块的两块侧板组成," + "不得按颜色数量代替物理段数。标注链可辅助定位候选接缝,必须再核对该高度外轮廓是否断开。" + "看不见背部时present=null,不能把未显示背板当作无背板。视图互相冲突必须指出各图支持什么,不能凭多数默认一张错误。" + "这一轮只做部件清单和功能总成识别,不同时承担逐图接缝定位,以免一个请求过载。" + "只返回JSON,全部字段必须完整:" + '{"drawers":{"count":null,"layout":"horizontal/vertical/mixed/unknown","confidence":0,"evidence":"","image_ids":[]},' + '"doors":{"count":null,"mechanism":"sliding/hinged/none/unknown","confidence":0,"evidence":"","image_ids":[]},' + '"back_panel":{"present":null,"confidence":0,"evidence":"","image_ids":[]},' + '"parts":[{"name":"","quantity":0,"component_role":"","assembly_group":"carcass/drawer/other",' + '"confidence":0,"evidence":"","image_ids":[]}],' + '"view_conflicts":[],"genuinely_invisible":[]}。' + "component_role只允许top/bottom/side/shelf/door/back/drawer_front/drawer_left/drawer_right/drawer_back/drawer_bottom/drawer_support/divider/leg/other。" + "drawer_support属于carcass;其余drawer_*属于drawer。上下排列vertical,左右并排horizontal,混合mixed;" + "不能由抽屉数量推断排列。相同规格零件合并数量,parts最多32项。证据一句话,image_ids必须用本轮原图编号。" + "不可见的结构只列genuinely_invisible;" + "不要编造部件,更不要请求用户重新数已清晰可见的部件。低置信先交给后续AI局部复读。" + )}) + raw = self._request_model_json({ + "model": self.model, "messages": [{"role": "user", "content": content}], + "temperature": 0.0, "response_format": {"type": "json_object"}, "max_tokens": 3072, + "enable_thinking": False, "vl_high_resolution_images": True, + }, "多视角结构关系识别失败", max_attempts=1, timeout_seconds=150) + if not isinstance(raw.get("parts"), list) or not raw["parts"]: + raise SolidWoodCostError("多视角结构关系结果不完整") + allowed_roles = {"top", "bottom", "side", "shelf", "door", "back", "drawer_front", "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", "drawer_support", "divider", "leg", "other"} + + def evidence_ids(value): + return sorted({int(x) for x in (value or []) if isinstance(x, int) and not isinstance(x, bool) and x in valid_ids}) + + parts = [] + for part in raw["parts"][:32]: + if not isinstance(part, dict): + continue + ids = evidence_ids(part.get("image_ids")) + role = str(part.get("component_role") or "") + count = int(self._float(part.get("quantity"), 0) or 0) + if not ids or not str(part.get("evidence") or "").strip() or role not in allowed_roles or not 1 <= count <= 50: + continue + parts.append({ + "source_structure_id": f"SOURCE-{len(parts) + 1:03d}", + "name": str(part.get("name") or "")[:80], "quantity": count, + "component_role": role, "assembly_group": str(part.get("assembly_group") or "other")[:40], + "confidence": min(1.0, max(0.0, self._float(part.get("confidence"), 0) or 0)), + "evidence": str(part.get("evidence") or "")[:240], "image_ids": ids, + }) + if not parts: + raise SolidWoodCostError("结构关系没有可回溯的原图证据") + adjudication = self._adjudicate_source_structure_views(raw, valid_ids) + focused_tasks = list(adjudication.get("machine_followups") or []) + focused_tasks.extend([ + "再次逐图确认同色上下模块之间是否仍有真实侧板接缝,不能按颜色数量计段", + "再次核对每个抽屉的独立抽面数量、上下/左右排列及箱体必需板件", + ]) + if focused_tasks: + try: + reread = self._reread_source_structure_conflicts( + assets, {**adjudication, "machine_followups": focused_tasks} + ) + # The focused result supersedes the first reading for the same + # image. It is not counted as an independent vote. + by_image = { + item.get("image_id"): item + for item in (raw.get("view_findings") or []) + if isinstance(item, dict) and item.get("image_id") in valid_ids + } + for item in (reread.get("view_findings") or [])[:16]: + if isinstance(item, dict) and item.get("image_id") in valid_ids: + by_image[item["image_id"]] = item + focused = self._adjudicate_source_structure_views( + {"view_findings": list(by_image.values())}, valid_ids + ) + focused["reread_performed"] = True + focused["initial_conflicts"] = adjudication.get("conflicts") or [] + adjudication = focused + except SolidWoodCostError as exc: + adjudication["reread_performed"] = False + adjudication["reread_error"] = re.sub(r"\s+", " ", str(exc)).strip()[-300:] + result = {"parts": parts, "view_conflicts": raw.get("view_conflicts") or [], + "genuinely_invisible": raw.get("genuinely_invisible") or [], + "side_panel_seams": raw.get("side_panel_seams") or [], + "view_adjudication": adjudication} + for key in ("drawers", "doors", "back_panel"): + fact = raw.get(key) if isinstance(raw.get(key), dict) else {} + result[key] = {**fact, "image_ids": evidence_ids(fact.get("image_ids"))} + return result + + def _vision_analyze(self, assets: list[dict], message: str) -> dict: + structure = {} + if assets: + try: + structure = self._read_source_structure(assets) + except SolidWoodCostError: + pass # Preserve the main visual path; independent CAD review remains required. + content = self._vision_content(assets, message) + if structure: + content.append({"type": "text", "text": ( + "以下是同一批原图的结构专项初读,不是公司BOM或最终答案。先核对图像再沿装配关系给逐件尺寸;" + "特别保留抽屉排列、固定承托板与移动底板的区别。每个对应部件带source_structure_id、component_role、assembly_group、structure_evidence。" + "不采信的项写uncertainties并给原图理由;禁止无理由漏掉专项初读中的板件。\n" + json.dumps(structure, ensure_ascii=False) + )}) + body = { + "model": self.model, + "messages": [{"role": "user", "content": content}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": 8192, + } + analysis = self._request_model_json(body, "家具多视图识别失败") + drawer_fact = structure.get("drawers") or {} + if ( + not structure.get("view_conflicts") + and drawer_fact.get("layout") in {"horizontal", "vertical", "mixed"} + and len(drawer_fact.get("image_ids") or []) >= 2 + and (self._float(drawer_fact.get("confidence"), 0) or 0) >= 0.85 + and drawer_fact.get("evidence") + and drawer_fact.get("count") == analysis.get("drawer_count") + ): + analysis.update(drawer_layout=drawer_fact["layout"], drawer_layout_confidence=drawer_fact["confidence"], drawer_layout_evidence=drawer_fact["evidence"]) + normalized = self._normalize_analysis(analysis) + normalized["source_structure_audit"] = structure or {"status": "retry_in_cad_review"} + dimension_asset = next( + (item for item in assets if str(item.get("role") or "") == "dimension"), + None, + ) + if dimension_asset: + try: + schedule = self._extract_dimension_schedule(dimension_asset, context_assets=assets) + normalized = self._apply_dimension_schedule( + normalized, schedule, + defer_geometry=bool(self._explicit_user_overall_dimensions(message)), + ) + except SolidWoodCostError: + normalized.setdefault("uncertainties", []).append( + "尺寸图部分标注未能稳定转录;已保留原始图片与识别依据,后续CAD复核继续检查" + ) + # A dimension drawing can describe a neighbouring SKU or list several + # optional sizes. The dimensions explicitly stated by the user in the + # current turn are therefore the final product-envelope authority. Do + # this *after* schedule transcription so a later image pass cannot + # silently restore the wrong variant. + normalized = self._apply_user_overall_dimensions(normalized, message) + # The user's explicit topology description is stronger evidence than a + # second visual guess. Persist it in the BOM before asking follow-up + # questions so corrections such as “无柜门/共5块层板” cannot be lost. + normalized = self._apply_user_structure_overrides(normalized, message) + # A single explicit authorization in the upload turn covers dimensions + # that the images genuinely do not show. Build labelled CAD estimates + # and let the independent multi-view review check them, instead of + # turning every inferred drawer wall into another questionnaire item. + if self._message_authorizes_cad_estimation(message): + normalized["estimation_consent"] = { + "approved": True, + "source": "user_authorized_with_source_images", + "scope": [ + str(item.get("label") or item.get("id") or "") + for item in self._low_confidence_dimension_items(normalized) + ], + "approved_at": self._now(), + } + if self._cabinet_carcass_missing(normalized): + normalized = self._add_inferred_cabinet_carcass(normalized) + # A successful source re-read may have resolved a question prepared by + # the first pass. Never present that stale question to the user. + normalized["structure_questions"] = self._critical_structure_questions(normalized) + return normalized + + @staticmethod + def _message_authorizes_cad_estimation(message: str) -> bool: + text = re.sub(r"\s+", "", str(message or "")).lower() + return bool( + ( + any(token in text for token in ("同意", "允许", "授权", "可以")) + and "预估" in text + and any(token in text for token in ("cad", "装配合理值", "合理值", "未标注")) + ) + or "按当前估算" in text + ) + + @staticmethod + def _message_requests_ai_cad_self_repair(message: str) -> bool: + """Recognize a repair command that supplies no new product facts.""" + text = re.sub(r"\s+", "", str(message or "")).lower() + asks_ai = "ai" in text and any(token in text for token in ("自行", "自动", "根据", "对照")) + asks_repair = any(token in text for token in ("修正", "修复", "重新生成", "复核", "定位漏件")) + explicit_fact = bool( + re.search( + r"(?:\d+(?:\.\d+)?\s*(?:mm|毫米|cm|厘米|公分|块|个|扇|段))|" + r"(?:无|没有|不带)(?:柜门|门板|背板)|(?:推拉门|移门|平开门)", + text, + re.I, + ) + ) + return asks_ai and asks_repair and not explicit_fact + + @staticmethod + def _explicit_user_overall_dimensions(message: str) -> dict[str, float] | None: + """Extract only an explicitly labelled product envelope from a turn. + + Unlabelled triples are intentionally ignored because they may be a + drawer, door, carton or component dimension. X/Y/Z follows the public + contract: width/depth/height. + """ + text = str(message or "") + if not text.strip(): + return None + label = r"(?:产品\s*)?(?:整体|外形|外观|成品|长宽高)(?:\s*(?:尺寸|规格))?" + number = r"(\d+(?:\.\d+)?)" + unit = r"(?:mm|毫米|cm|厘米|公分)?" + separator = r"\s*(?:×|x|X|\*)\s*" + pattern = re.compile( + rf"{label}\s*(?:按|为|是|采用|以|:|:|=)?\s*" + rf"{number}\s*{unit}{separator}{number}\s*{unit}{separator}{number}\s*" + rf"(?Pmm|毫米|cm|厘米|公分)?", + re.I, + ) + match = pattern.search(text) + if not match: + return None + values = [float(match.group(index)) for index in (1, 2, 3)] + matched_text = match.group(0).lower() + final_unit = str(match.group("final_unit") or "").lower() + is_cm = final_unit in {"cm", "厘米", "公分"} or bool( + re.search(r"(?:cm|厘米|公分)", matched_text, re.I) + ) + is_mm = final_unit in {"mm", "毫米"} or bool(re.search(r"(?:mm|毫米)", matched_text, re.I)) + if is_cm and not is_mm: + values = [value * 10.0 for value in values] + elif not is_mm and max(values) <= 300.0: + # Furniture dimensions written without a unit are conventionally + # centimetres at this scale; larger triples are already millimetres. + values = [value * 10.0 for value in values] + if any(value < 20.0 or value > 20_000.0 for value in values): + return None + return {axis: values[index] for index, axis in enumerate(("x", "y", "z"))} + + def _apply_user_overall_dimensions(self, analysis: dict, message: str) -> dict: + explicit = self._explicit_user_overall_dimensions(message) + if not explicit: + return analysis + updated = json.loads(json.dumps(analysis, ensure_ascii=False)) + previous = { + axis: float((updated.get("overall_dimensions_mm") or {}).get(axis) or 0.0) + for axis in ("x", "y", "z") + } + if any(abs(previous[axis] - explicit[axis]) > 0.5 for axis in ("x", "y", "z")): + updated.setdefault("dimension_conflicts", []).append( + { + "kind": "overall_dimensions", + "rejected_dimensions_mm": previous, + "accepted_dimensions_mm": dict(explicit), + "resolution": "用户本轮明确整体尺寸优先于图片与尺寸表识别", + } + ) + updated["overall_dimensions_mm"] = dict(explicit) + updated["dimension_evidence"] = { + axis: { + "value": float(explicit[axis]), + "source": "用户本轮明确整体尺寸", + "confidence": 1.0, + } + for axis in ("x", "y", "z") + } + updated["user_dimension_contract"] = { + "overall_dimensions_mm": dict(explicit), + "source": "current_user_turn", + "confidence": 1.0, + } + components = self._canonicalize_component_geometry( + list(updated.get("components") or []), + explicit, + has_doors=bool(updated.get("has_doors")), + strict_envelope=True, + ) + updated["components"] = self._canonicalize_simple_cabinet_origins( + components, + explicit, + has_doors=bool(updated.get("has_doors")), + door_mechanism=str(updated.get("door_mechanism") or ""), + ) + updated["structure_questions"] = self._critical_structure_questions(updated) + updated["process_evidence"] = self._process_evidence(updated) + return updated + + def _adjudicate_source_dimension_label_scopes( + self, + asset: dict, + labels: list[dict], + missing_axes: list[str], + *, + context_assets: list[dict] | None = None, + ) -> dict: + """Resolve axis/scope for already-transcribed labels from source pixels. + + OCR and geometric interpretation are deliberately separated. This + pass is not allowed to invent or alter a number: it may only select a + candidate label by its stable index and decide whether the arrow spans + the complete product envelope. That prevents a clearly transcribed + overall width from being discarded merely because an earlier OCR pass + returned ``scope=unknown``, without promoting a drawer or shelf label. + """ + review_url = self._asset_review_data_url(asset) + candidates = [] + for index, row in enumerate(labels): + if not isinstance(row, dict): + continue + axis = str(row.get("axis") or "").lower() + value = self._float(row.get("value")) + unit = str(row.get("unit") or "").lower() + if ( + axis not in missing_axes or value is None or value <= 0 + or unit not in {"mm", "cm", "m"} + or str(row.get("scope") or "unknown").lower() == "overall" + ): + continue + candidates.append({ + "candidate_index": index, + "axis": axis, + "value": value, + "unit": unit, + "scope": str(row.get("scope") or "unknown").lower(), + "text": str(row.get("text") or "")[:80], + "location": str(row.get("location") or "")[:220], + "source_pass": str(row.get("source_pass") or "unknown"), + }) + if not review_url or not candidates or not missing_axes: + return {"decisions": [], "missing_axes": list(missing_axes)} + content: list[dict] = [{ + "type": "text", + "text": ( + "你是家具工程图尺寸线裁决员。本轮不做OCR,不得新增、修改、换算任何数字;" + "只能从候选清单按candidate_index选择。必须回看原图中该尺寸线的两个箭头/引出线端点。" + "产品坐标固定:X=正面左右宽,Y=正面向后深,Z=底到顶高。" + "只有端点跨越成品该轴最外侧边界才可scope=overall;抽面、门片、顶板净宽、层间净空、" + "单个上下模块均不是overall。斜投影视图按尺寸线在实体上的平行方向判轴,不能按纸面斜率判。" + f"当前缺失整体轴={','.join(missing_axes)}。" + "只返回JSON:{\"decisions\":[{\"candidate_index\":0,\"axis\":\"x\"," + "\"scope\":\"overall/part/module/unknown\",\"confidence\":0.0," + "\"endpoint_evidence\":\"左端...右端...\"}],\"missing_axes\":[]}。" + "confidence>=0.90仅用于两端都清晰可见的尺寸线;看不清必须unknown。\n候选清单:" + + json.dumps(candidates, ensure_ascii=False) + ), + }] + for other in sorted( + list(context_assets or []), + key=lambda item: {"front": 0, "side": 1, "open_or_third": 2}.get(str(item.get("role") or ""), 9), + )[:2]: + if other is asset or other.get("value") == asset.get("value"): + continue + url = self._asset_review_data_url(other) + if url: + content.extend([ + {"type": "text", "text": f"方向参考原图,候选视角={other.get('role') or 'unknown'}:"}, + {"type": "image_url", "image_url": {"url": url}}, + ]) + content.extend([ + {"type": "text", "text": "需要裁决的原始尺寸图:"}, + {"type": "image_url", "image_url": {"url": review_url}}, + ]) + result = self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": content}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": 1800, + "enable_thinking": True, + "thinking_budget": 1024, + "vl_high_resolution_images": True, + }, "尺寸线范围裁决失败", max_attempts=1, timeout_seconds=150) + accepted = [] + seen = set() + for decision in (result.get("decisions") or [])[:24]: + if not isinstance(decision, dict): + continue + raw_index = self._float(decision.get("candidate_index"), -1) + index = int(raw_index if raw_index is not None else -1) + if index < 0 or index >= len(labels) or index in seen: + continue + axis = str(decision.get("axis") or "").lower() + scope = str(decision.get("scope") or "unknown").lower() + confidence = min(1.0, max(0.0, self._float(decision.get("confidence"), 0) or 0)) + evidence = str(decision.get("endpoint_evidence") or "").strip() + original = labels[index] + # Axis and numeric transcription are immutable in this pass. + if axis != str(original.get("axis") or "").lower() or scope not in {"overall", "part", "module", "unknown"}: + continue + if scope == "overall" and (axis not in missing_axes or confidence < 0.90 or len(evidence) < 8): + continue + accepted.append({ + "candidate_index": index, + "axis": axis, + "scope": scope, + "confidence": confidence, + "endpoint_evidence": evidence[:260], + }) + seen.add(index) + decided_axes = {row["axis"] for row in accepted if row["scope"] == "overall"} + return { + "decisions": accepted, + "missing_axes": [axis for axis in missing_axes if axis not in decided_axes], + } + + def _read_source_dimension_labels(self, asset: dict, context_assets: list[dict] | None = None) -> dict: + """Focused fallback: read arrows only, with no proposed BOM or answers.""" + review_url = self._asset_review_data_url(asset) + if not review_url: + raise SolidWoodCostError("尺寸标注原图不可读") + + def decode(raw: str) -> dict: + data = self._decode_model_json(raw) + if not isinstance(data.get("overall_dimension_labels"), list) or not isinstance(data.get("missing_overall_axes"), list): + raise ValueError("尺寸复读缺少逐条标注与缺失轴字段") + return data + + context_content = [] + # The source drawing is the authoritative pixel input. Two compact + # orientation references are sufficient and avoid an overloaded + # four-image request that frequently times out before valid JSON. + context_candidates = sorted( + list(context_assets or []), + key=lambda item: {"front": 0, "side": 1, "open_or_third": 2}.get(str(item.get("role") or ""), 9), + ) + for other in context_candidates[:2]: + if other is asset or other.get("value") == asset.get("value"): + continue + url = self._asset_review_data_url(other) + if url: + context_content.extend([ + {"type": "text", "text": f"同次上传参考图,候选视角{other.get('role') or 'unknown'},请按画面核验:"}, + {"type": "image_url", "image_url": {"url": url}}, + ]) + first = self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": context_content + [ + {"type": "text", "text": "下面是需要逐条读取的详细尺寸图:"}, + {"type": "image_url", "image_url": {"url": review_url}}, + {"type": "text", "text": ( + "本次只读尺寸箭头,不拆BOM、不生成配件、不计算成本。先观察产品正面与侧面方向," + "X为左右宽度,Y为前后深度,Z为上下高度。逐条读取图上所有直接尺寸标注,最多20条;" + "不仅要读整体长宽高,还必须读从底到顶连续排列的模块高度链、层间净空、抽面宽高和局部板宽。" + "先建立图上的三条投影基准:带抽屉/门面的一面是正面,沿该面的水平长边是X;" + "从正面退向后沿、平行侧板水平边的是Y;竖向立边是Z。逐条比较尺寸线与这三条基准的平行关系," + "不能把纸面上斜着的线都当深度,也不能把抽屉面板的左右长边说成前后深度。" + "每条必须按两端引出线说明量的是哪里。局部值必须保留并正确写scope=module或part," + "不能因为本次最终只强制整体外形就省略;同时不要把模块高度、净空宽、门片宽当成整体外形。" + "用其他原图验证产品正面方向和单位,同一整体尺寸在照片和线稿的数字比例可用于核验单位;" + "若采用其他图确定单位,location中明确引用那张图的原标注。不能按常见家具尺寸猜单位。" + "只返回JSON:{\"overall_dimension_labels\":[],\"missing_overall_axes\":[]}。" + "数组中的每条对象有axis(x/y/z)、value(图上原数值)、unit(mm/cm/m)、scope(overall/part/module/unknown)、" + "text(标注原文)、location(两端在产品上的位置)、confidence(0到1)。整体轴未直接标注就写入missing_overall_axes," + "不能按比例猜值。单位不明确时unit=unknown并降低置信度。" + )}, + ]}], "temperature": 0.0, "max_tokens": 2300, "response_format": {"type": "json_object"}, + "enable_thinking": False, "vl_high_resolution_images": True, + }, "尺寸箭头独立复读失败", decoder=decode, max_attempts=1, timeout_seconds=150) + # A single vision response is not stable enough to become a machining + # constraint. Run a second, deliberately narrower transcription from + # the original pixels. Equal readings corroborate each other; a + # different value remains a conflict in build_dimension_ledger, while + # an axis omitted by one pass may still be recovered by the other. + compact_context = [] + for other in context_candidates: + if other is asset or other.get("value") == asset.get("value"): + continue + url = self._asset_review_data_url(other) + if url: + compact_context = [ + {"type": "text", "text": "同次上传的正面/侧面方向参考,只用于判定X/Y/Z:"}, + {"type": "image_url", "image_url": {"url": url}}, + ] + break + second_error = "" + try: + second = self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": compact_context + [ + {"type": "image_url", "image_url": {"url": review_url}}, + {"type": "text", "text": ( + "独立重读这张尺寸图,不得参考任何上一次答案。" + "只找跨越整个成品外轮廓的宽X、深Y、高Z尺寸线,必须根据尺寸线两端引出线判断scope。" + "门片、抽面、单个模块和局部净空不能当整体尺寸。看不到就写缺失,不按比例猜。" + "只返回JSON:{\"overall_dimension_labels\":[],\"missing_overall_axes\":[]}。" + "每条含axis、value、unit、scope、text、location、confidence。" + )}, + ]}], + "temperature": 0.0, "max_tokens": 1400, + "response_format": {"type": "json_object"}, + "enable_thinking": False, "vl_high_resolution_images": True, + }, "尺寸箭头一致性复读失败", decoder=decode, max_attempts=1, timeout_seconds=120) + except SolidWoodCostError as exc: + second = {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")} + second_error = re.sub(r"\s+", " ", str(exc)).strip()[-300:] + def normalize_label(row, source_pass="unknown"): + if not isinstance(row, dict): + return row + normalized = dict(row) + normalized["axis"] = str(normalized.get("axis") or "").lower() + scope = str(normalized.get("scope") or "unknown").lower() + normalized["scope"] = scope if scope in {"overall", "part", "module", "unknown"} else "unknown" + normalized["source_pass"] = str(source_pass) + return normalized + + labels = [ + *[normalize_label(row, "full_transcription") for row in list(first.get("overall_dimension_labels") or [])], + *[normalize_label(row, "independent_overall") for row in list(second.get("overall_dimension_labels") or [])], + ] + readable_axes = { + row.get("axis") for row in labels + if isinstance(row, dict) and row.get("scope") == "overall" + } + missing_axes = [axis for axis in "xyz" if axis not in readable_axes] + focused_error = "" + focused_used = False + annotation_reread_used = False + annotation_reread_error = "" + focus_sheet = self._dimension_focus_sheet_data_url(asset) if missing_axes else "" + annotation_sheet = self._dimension_annotation_focus_data_url(asset) if missing_axes else "" + if missing_axes and focus_sheet: + focused_used = True + try: + focused_content = [ + {"type": "text", "text": ( + "下图是同一张原始尺寸图的4个重叠放大区域,不是4张不同图。" + f"前两次全图读取仍缺少整体轴{','.join(missing_axes)}。" + "只在像素中查找这些缺失轴的尺寸线;必须看清数字、单位和两端引出线是否跨越整个成品最大外轮廓。" + "抽面、门片、层板、净空或单模块仍只能写part/module,不能写overall。" + "只返回JSON:{\"overall_dimension_labels\":[],\"missing_overall_axes\":[]};" + "axis只能小写x/y/z,scope只能overall/part/module/unknown。" + )}, + {"type": "image_url", "image_url": {"url": focus_sheet}}, + ] + if annotation_sheet: + focused_content.extend([ + {"type": "text", "text": ( + "下面是从同一原图按颜色对比度机械提取的标注增强层,只帮助辨认字符和尺寸线," + "不包含AI推断;仍必须回到上一张原图放大区域判断尺寸线端点和scope。" + )}, + {"type": "image_url", "image_url": {"url": annotation_sheet}}, + ]) + focused = self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": focused_content}], + "temperature": 0.0, "max_tokens": 1200, + "response_format": {"type": "json_object"}, + "enable_thinking": False, "vl_high_resolution_images": True, + }, "缺失整体尺寸轴局部复读失败", decoder=decode, max_attempts=1, timeout_seconds=120) + labels.extend(normalize_label(row, "quadrant_focus") for row in focused.get("overall_dimension_labels") or []) + readable_axes = { + row.get("axis") for row in labels + if isinstance(row, dict) and row.get("scope") == "overall" + } + missing_axes = [axis for axis in "xyz" if axis not in readable_axes] + except SolidWoodCostError as exc: + focused_error = re.sub(r"\s+", " ", str(exc)).strip()[-300:] + if missing_axes and annotation_sheet: + annotation_reread_used = True + try: + annotation_read = self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": [ + {"type": "text", "text": ( + "这是尺寸图的最终机器复读,不参考此前答案。第一张是原图,第二张只是从同一原图机械提取的红色标注增强层。" + "先逐个抄录增强层中看见的全部数字,再回到原图沿每条尺寸线两端判断X左右、Y前后、Z上下以及" + "overall/part/module范围。不能因为小数或文字旋转就省略;也不能把局部抽面、层板或模块尺寸提升成整体。" + f"当前仍缺整体轴{','.join(missing_axes)},但仍应返回看见的其他局部标注供模块结构复核。" + "只返回JSON:{\"overall_dimension_labels\":[],\"missing_overall_axes\":[]}。" + "每条含axis、value、unit、scope、text、location、confidence。" + )}, + {"type": "image_url", "image_url": {"url": review_url}}, + {"type": "image_url", "image_url": {"url": annotation_sheet}}, + ]}], + "temperature": 0.0, "max_tokens": 1800, + "response_format": {"type": "json_object"}, + "enable_thinking": False, "vl_high_resolution_images": True, + }, "尺寸标注增强层复读失败", decoder=decode, max_attempts=1, timeout_seconds=120) + labels.extend(normalize_label(row, "annotation_focus") for row in annotation_read.get("overall_dimension_labels") or []) + readable_axes = { + row.get("axis") for row in labels + if isinstance(row, dict) and row.get("scope") == "overall" + } + missing_axes = [axis for axis in "xyz" if axis not in readable_axes] + except SolidWoodCostError as exc: + annotation_reread_error = re.sub(r"\s+", " ", str(exc)).strip()[-300:] + scope_adjudication = {"decisions": [], "missing_axes": list(missing_axes)} + scope_adjudication_error = "" + if missing_axes: + try: + scope_adjudication = self._adjudicate_source_dimension_label_scopes( + asset, labels, missing_axes, context_assets=context_assets + ) + for decision in scope_adjudication.get("decisions") or []: + if decision.get("scope") != "overall": + continue + index = int(decision["candidate_index"]) + labels[index] = { + **labels[index], + "scope": "overall", + "scope_confidence": decision.get("confidence"), + "scope_evidence": decision.get("endpoint_evidence"), + "scope_source": "source_pixel_endpoint_adjudication", + } + readable_axes = { + row.get("axis") for row in labels + if isinstance(row, dict) and row.get("scope") == "overall" + } + missing_axes = [axis for axis in "xyz" if axis not in readable_axes] + except SolidWoodCostError as exc: + scope_adjudication_error = re.sub(r"\s+", " ", str(exc)).strip()[-300:] + return { + "overall_dimension_labels": labels, + "missing_overall_axes": missing_axes, + "independent_read_count": 1 if second_error else 2, + "independent_reread_error": second_error or None, + "focused_missing_axis_read": focused_used, + "focused_missing_axis_error": focused_error or None, + "annotation_reread_used": annotation_reread_used, + "annotation_reread_error": annotation_reread_error or None, + "scope_adjudication_used": bool(scope_adjudication.get("decisions")), + "scope_adjudication": scope_adjudication, + "scope_adjudication_error": scope_adjudication_error or None, + } + + def _extract_dimension_schedule(self, asset: dict, *, context_assets: list[dict] | None = None) -> dict: + review_url = self._asset_review_data_url(asset) + if not review_url: + raise SolidWoodCostError("尺寸图无法进入专用转录流程") + content = [ + { + "type": "text", + "text": ( + "你是家具尺寸表转录员,只读取这一张尺寸/工程图。" + "如图中存在COMPONENT SCHEDULE、BOM、零件表或明确零件标注,必须逐行原样转录名称、尺寸和括号数量;" + "不得根据外观猜数量,不得把一条层板变成两条。产品坐标固定为X=左右宽、Y=前后深、Z=上下高:" + "顶/底/层板{x:宽,y:深,z:厚},侧板{x:厚,y:深,z:高},门/背板{x:宽,y:厚,z:高},腿{x:截面宽,y:截面深,z:腿高}。" + "只返回JSON:" + '{"table_present":false,"confidence":0,"overall_dimension_labels":[' + '{"axis":"x/y/z","value":0,"unit":"mm/cm/m","scope":"overall/part/module/unknown",' + '"text":"标注原文","location":"标注线两端在产品上的位置","confidence":0}],' + '"missing_overall_axes":[], ' + '"rows":[{"role":"top/bottom/side/shelf/door/back/leg/drawer_front/drawer_left/drawer_right/drawer_back/drawer_bottom/other",' + '"source_name":"","quantity":0,"dimensions_mm":{"x":0,"y":0,"z":0},' + '"source_text":"","confidence":0}],' + '"hardware":[{"name":"","quantity":0,"source_text":"","confidence":0}]}' + "。这是字段格式,不含任何产品答案。没有零件表时table_present=false、rows=[]," + "但仍须独立读取尺寸箭头。overall_dimension_labels逐条记录看见的标注,不能只抄第一次识别的尺寸。" + "先检查标注线的两端及方向:竖向模块高度应axis=z、scope=module,不能当成整体宽;" + "层板或门片内宽应scope=part,不能冒充成品外宽。只有跨越整个成品外轮廓才写scope=overall。" + "保留图上的原数值和单位,由程序转换毫米;不得将厘米数直接标成毫米。" + "未找到直接标注的整体轴放入missing_overall_axes,不能猜填或用局部替代。看不清的行不要输出。" + ), + }, + {"type": "image_url", "image_url": {"url": review_url}}, + ] + schedule = self._request_model_json( + { + "model": self.model, + "messages": [{"role": "user", "content": content}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": 4096, + }, + "尺寸表专用二次转录失败", + decoder=self._decode_dimension_schedule_json, + ) + if not isinstance(schedule.get("overall_dimension_labels"), list): + # A table-oriented pass can omit the independent arrow task. Ask + # the AI a smaller source-only question, not the end user. + try: + schedule.update(self._read_source_dimension_labels(asset, context_assets)) + except SolidWoodCostError: + # Failure of the extra audit must not discard a valid parts + # table or pretend that the old dimensions were verified. + schedule["source_dimension_audit_status"] = "retry_in_cad_review" + return schedule + + @classmethod + def _decode_dimension_schedule_json(cls, raw: str) -> dict: + """Decode or salvage the row ledger from a truncated model payload.""" + text = re.sub(r"^```(?:json)?\s*|\s*```$", "", str(raw or "").strip(), flags=re.S) + try: + decoded = cls._decode_model_json(text) + if "table_present" in decoded and isinstance(decoded.get("rows"), list): + return decoded + except (ValueError, TypeError, json.JSONDecodeError, SolidWoodCostError): + pass + + table_match = re.search(r'"table_present"\s*:\s*(true|false)', text, re.I) + if not table_match: + raise ValueError("尺寸表返回缺少table_present") + confidence_match = re.search(r'"confidence"\s*:\s*(0(?:\.\d+)?|1(?:\.0+)?)', text) + result: dict = { + "table_present": table_match.group(1).lower() == "true", + "confidence": float(confidence_match.group(1)) if confidence_match else 0.80, + "rows": [], + "hardware": [], + } + overall_key = re.search(r'"overall_dimensions_mm"\s*:', text) + if overall_key: + start = text.find("{", overall_key.end()) + fragment = cls._json_fragment(text, start) + if fragment: + try: + result["overall_dimensions_mm"] = json.loads(fragment) + except json.JSONDecodeError: + pass + + def recover_array(key: str) -> list[dict]: + marker = re.search(rf'"{re.escape(key)}"\s*:', text) + if not marker: + return [] + start = text.find("[", marker.end()) + if start < 0: + return [] + # A later top-level property marks the end even when the model + # omitted the closing array bracket. + later = re.search(r'\n\s*"(?:hardware|notes|assumptions)"\s*:', text[start + 1:]) + end = start + 1 + later.start() if later else len(text) + source = text[start:end] + recovered: list[dict] = [] + cursor = 1 + while cursor < len(source) and len(recovered) < 100: + object_start = source.find("{", cursor) + if object_start < 0: + break + fragment = cls._json_fragment(source, object_start) + if not fragment: + break + candidate = re.sub(r",\s*}", "}", fragment) + try: + item = json.loads(candidate) + except json.JSONDecodeError: + cursor = object_start + 1 + continue + if isinstance(item, dict): + recovered.append(item) + cursor = object_start + len(fragment) + return recovered + + result["rows"] = recover_array("rows") + result["hardware"] = recover_array("hardware") + if result["table_present"] and not result["rows"]: + raise ValueError("尺寸表返回中没有可恢复的零件行") + return result + + @staticmethod + def _schedule_role(item: dict) -> str: + raw_role = str(item.get("role") or "").strip().lower().replace("-", "_").replace(" ", "_") + aliases = { + "top_panel": "top", "bottom_panel": "bottom", "side_panel": "side", + "door_panel": "door", "back_panel": "back", "shelf_panel": "shelf", + } + if raw_role in aliases: + return aliases[raw_role] + if raw_role in { + "top", "bottom", "side", "shelf", "door", "back", "leg", + "drawer_front", "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", "other", + }: + return raw_role + text = f"{item.get('source_name') or ''} {item.get('source_text') or ''}".lower() + candidates = ( + ("drawer_left", ("抽屉左", "drawer left")), + ("drawer_right", ("抽屉右", "drawer right")), + ("drawer_back", ("抽屉后", "drawer back")), + ("drawer_bottom", ("抽屉底", "drawer bottom")), + ("drawer_front", ("抽屉面", "drawer front")), + ("top", ("顶板", "桌面", "台面", "top panel", "tabletop", "desktop")), + ("bottom", ("底板", "bottom panel")), + ("side", ("侧板", "side panel")), + ("shelf", ("层板", "搁板", "层隔板", "shelf")), + ("door", ("柜门", "门板", "door")), + ("back", ("背板", "back panel")), + ("leg", ("腿", "脚", "leg")), + ) + return next((role for role, tokens in candidates if any(token in text for token in tokens)), "other") + + @classmethod + def _component_schedule_role(cls, item: dict) -> str: + if item.get("component_role") == "drawer_support" and item.get("assembly_group") == "carcass": + return "shelf" + key = cls._component_key(item) + if any(token in key for token in ("抽屉左", "drawer left")): + return "drawer_left" + if any(token in key for token in ("抽屉右", "drawer right")): + return "drawer_right" + if any(token in key for token in ("抽屉后", "drawer back")): + return "drawer_back" + if any(token in key for token in ("抽屉底", "drawer bottom")): + return "drawer_bottom" + if cls._is_drawer_front(item): + return "drawer_front" + if any(token in key for token in ("顶板", "桌面", "台面", "top panel", "tabletop", "desktop")): + return "top" + if any(token in key for token in ("柜体底板", "柜底板", "bottom panel", "底板")) and "抽屉" not in key: + return "bottom" + if any(token in key for token in ("侧板", "side panel")) and "抽屉" not in key: + return "side" + if cls._is_shelf_panel(item): + return "shelf" + if cls._is_door_panel(item): + return "door" + if cls._is_back_panel(item): + return "back" + if cls._is_leg_component(item): + return "leg" + return "other" + + @classmethod + def _schedule_conflicting_facades( + cls, + components: list[dict], + schedule_rows: list[dict], + overall: dict, + ) -> list[dict]: + """Find large visual facade guesses contradicted by an exact schedule. + + A multimodal pass can mistake the closed front elevation for one solid + ``front panel`` even though the engineering schedule separately lists + the cabinet doors. Keeping both creates overlapping CAD geometry and + charges the same front surface for material, finish and labour twice. + Narrow rails/aprons are intentionally retained; only a facade covering + a substantial share of the full front elevation is rejected. + """ + schedule_roles = {str(row.get("role") or "") for row in schedule_rows} + if "door" not in schedule_roles or not {"top", "bottom", "side"}.issubset(schedule_roles): + return [] + overall_x = max(0.0, float(overall.get("x") or 0.0)) + overall_z = max(0.0, float(overall.get("z") or 0.0)) + front_area = overall_x * overall_z + if front_area <= 0: + return [] + door_area = sum( + max(1, int(item.get("quantity") or 1)) + * float((item.get("dimensions_mm") or {}).get("x") or 0.0) + * float((item.get("dimensions_mm") or {}).get("z") or 0.0) + for item in components + if cls._is_door_panel(item) + ) + if door_area < front_area * 0.35: + return [] + conflicts: list[dict] = [] + for item in components: + key = cls._component_key(item) + if any(token in key for token in ("抽屉", "drawer")) or cls._is_door_panel(item): + continue + if not any(token in key for token in ("前挡板", "前面板", "整面板", "front panel", "facade panel")): + continue + dims = item.get("dimensions_mm") or {} + width = max(0.0, float(dims.get("x") or 0.0)) + height = max(0.0, float(dims.get("z") or 0.0)) + if ( + width >= overall_x * 0.65 + and height >= overall_z * 0.45 + and width * height >= front_area * 0.40 + ): + conflicts.append(item) + return conflicts + + @classmethod + def _remove_schedule_conflicting_facades( + cls, + components: list[dict], + schedule_rows: list[dict], + overall: dict, + ) -> tuple[list[dict], list[dict]]: + conflicts = cls._schedule_conflicting_facades(components, schedule_rows, overall) + if not conflicts: + return components, [] + conflict_ids = {id(item) for item in conflicts} + audit = [ + { + "part_id": str(item.get("part_id") or ""), + "name": str(item.get("name") or ""), + "quantity": max(1, int(item.get("quantity") or 1)), + "dimensions_mm": dict(item.get("dimensions_mm") or {}), + "reason": "高置信尺寸表已单独列出柜门,该大面积前立面件未在尺寸表中,与柜门覆盖范围重叠", + "source": "dimension_schedule_facade_conflict_gate", + } + for item in conflicts + ] + return [item for item in components if id(item) not in conflict_ids], audit + + @classmethod + def _apply_source_dimension_labels(cls, analysis: dict, schedule: dict) -> dict: + """Accept arrow-level source evidence, never a free-form guessed triple. + + This pass is independent of whether a parts table exists. Scope, units + and arrow endpoints are required so local/module labels cannot become + a high-confidence product envelope. Conflicting labels remain visible + in the audit instead of being resolved by last-write-wins. + """ + result = json.loads(json.dumps(analysis, ensure_ascii=False)) + labels = schedule.get("overall_dimension_labels") + if not isinstance(labels, list): + return result + candidates: dict[str, list[dict]] = {axis: [] for axis in ("x", "y", "z")} + units = {"mm": 1.0, "毫米": 1.0, "cm": 10.0, "厘米": 10.0, "m": 1000.0, "米": 1000.0} + rejected = [] + for label in labels[:80]: + if not isinstance(label, dict): + continue + axis = str(label.get("axis") or "").lower() + unit = str(label.get("unit") or "").lower() + confidence = min(1.0, max(0.0, cls._float(label.get("confidence"), 0.0) or 0.0)) + value = (cls._float(label.get("value"), 0.0) or 0.0) * units.get(unit, 0.0) + if ( + axis not in candidates or label.get("scope") != "overall" + or confidence < 0.85 or not 20 <= value <= 20000 + or not str(label.get("text") or "").strip() + or not str(label.get("location") or "").strip() + ): + rejected.append(label) + continue + candidates[axis].append({**label, "value_mm": value, "confidence": confidence}) + audit = {"source": "independent_dimension_arrow_transcription", "accepted": {}, "rejected": rejected, "conflicts": {}} + overall = result.setdefault("overall_dimensions_mm", {}) + evidence = result.setdefault("dimension_evidence", {}) + explicit = (result.get("user_dimension_contract") or {}).get("overall_dimensions_mm") or {} + missing = set(schedule.get("missing_overall_axes") or []) + for axis, records in candidates.items(): + if axis in explicit: + continue + values = [record["value_mm"] for record in records] + if values and (axis in missing or max(values) - min(values) > max(1.0, min(values) * 0.005)): + audit["conflicts"][axis] = records + missing.add(axis) + elif records: + best = max(records, key=lambda record: record["confidence"]) + overall[axis] = best["value_mm"] + evidence[axis] = { + "value": best["value_mm"], + "source": f"尺寸图独立标注复读:{best['text']};{best['location']}", + "confidence": best["confidence"], + "kind": "source_arrow_label", "unit": "mm", + "source_value": best["value"], "source_unit": best["unit"], + } + audit["accepted"][axis] = best + continue + if axis in missing: + old = dict(evidence.get(axis) or {}) + evidence[axis] = { + **old, "value": float(overall.get(axis) or 0.0), + "source": "独立复读未找到无冲突的整体轴标注;当前值为待验证估算", + "confidence": min(0.55, cls._float(old.get("confidence"), 0.0) or 0.0), + } + result["source_dimension_audit"] = audit + return result + + def _apply_dimension_schedule(self, analysis: dict, schedule: dict, *, defer_geometry: bool = False) -> dict: + if not isinstance(schedule, dict): + return analysis + updated = self._apply_source_dimension_labels(analysis, schedule) + if not bool(schedule.get("table_present")): + return updated + schedule_confidence = min(1.0, max(0.0, float(schedule.get("confidence") or 0.0))) + if schedule_confidence < 0.80: + return updated + # A naked overall_dimensions_mm triple from a second model pass has + # neither unit nor arrow endpoints. It must not overwrite the atomic + # value + source ledger, even when the table parser claims confidence. + components = list(updated.get("components") or []) + role_defaults = { + "top": ("TOP", "顶板", "主体结构"), + "bottom": ("BOT", "柜体底板", "主体结构"), + "side": ("SIDE", "侧板", "主体结构"), + "shelf": ("SHELF", "层板", "主体结构"), + "door": ("DOOR", "柜门", "柜门"), + "back": ("BACK", "背板", "背板"), + "leg": ("LEG", "腿", "主体结构"), + "drawer_front": ("DRAWER-F", "抽屉面板", "抽屉面板"), + "drawer_left": ("DRAWER-L", "抽屉左帮板(左侧板)", "抽屉结构"), + "drawer_right": ("DRAWER-R", "抽屉右帮板(右侧板)", "抽屉结构"), + "drawer_back": ("DRAWER-BACK", "抽屉后帮板(后侧板)", "抽屉结构"), + "drawer_bottom": ("DRAWER-BOTTOM", "抽屉底板", "抽屉底板"), + } + schedule_rows: list[dict] = [] + for raw in (schedule.get("rows") or [])[:80]: + if not isinstance(raw, dict): + continue + role = self._schedule_role(raw) + dims_raw = raw.get("dimensions_mm") if isinstance(raw.get("dimensions_mm"), dict) else {} + dims = {axis: float(dims_raw.get(axis) or 0.0) for axis in ("x", "y", "z")} + quantity = max(1, min(50, int(float(raw.get("quantity") or 1)))) + confidence = min(1.0, max(0.0, float(raw.get("confidence") or schedule_confidence))) + source_text = str(raw.get("source_text") or raw.get("source_name") or "")[:200] + if role == "other" or confidence < 0.80 or any(value <= 0 for value in dims.values()) or not source_text: + continue + schedule_rows.append({"role": role, "dims": dims, "quantity": quantity, "confidence": confidence, "source_text": source_text}) + role_row_counts: dict[str, int] = {} + for row in schedule_rows: + role_row_counts[row["role"]] = role_row_counts.get(row["role"], 0) + 1 + for row in schedule_rows: + matches = [item for item in components if self._component_schedule_role(item) == row["role"]] + if not matches and row["role"] in role_defaults: + prefix, name, material_group = role_defaults[row["role"]] + used = {str(item.get("part_id") or "") for item in components} + part_id = f"{prefix}-001" + while part_id in used: + part_id += "X" + matches = [{ + "part_id": part_id, "name": name, "quantity": row["quantity"], "shape": "box", + "dimensions_mm": dict(row["dims"]), "origins_mm": [], "material_group": material_group, + "recommended_species": "主体材质", "coating_area_m2": 0.0, + "edge_banding": {"exposed_length_m": 0.0, "confirmed": False, "includes_adhesive": False}, + "machining": {"holes": 0, "slots": 0, "slot_length_m": 0.0, "mortise_tenon": 0, "edge_profile_m": 0.0, "carving_area_m2": 0.0, "carving_path_m": 0.0, "evidence": "", "confidence": 0.0}, + "confidence": row["confidence"], + }] + components.extend(matches) + elif role_row_counts.get(row["role"]) == 1: + # A complete schedule row is the quantity authority. General + # vision may split the same role into duplicate records; keep + # one manufacturing line and remove every redundant copy. + primary = matches[0] + if row["role"] in role_defaults: + primary["name"] = role_defaults[row["role"]][1] + primary["material_group"] = role_defaults[row["role"]][2] + components = [ + item for item in components + if item is primary or self._component_schedule_role(item) != row["role"] + ] + matches = [primary] + for item in matches: + item["dimensions_mm"] = dict(row["dims"]) + axis_labels = { + "top": {"x": "宽", "y": "深", "z": "厚"}, + "bottom": {"x": "宽", "y": "深", "z": "厚"}, + "shelf": {"x": "宽", "y": "深", "z": "厚"}, + "side": {"x": "厚", "y": "深", "z": "高"}, + "door": {"x": "宽", "y": "厚", "z": "高"}, + "back": {"x": "宽", "y": "厚", "z": "高"}, + "leg": {"x": "宽", "y": "深", "z": "高"}, + "drawer_front": {"x": "宽", "y": "厚", "z": "高"}, + "drawer_left": {"x": "厚", "y": "深", "z": "高"}, + "drawer_right": {"x": "厚", "y": "深", "z": "高"}, + "drawer_back": {"x": "宽", "y": "厚", "z": "高"}, + "drawer_bottom": {"x": "宽", "y": "深", "z": "厚"}, + }.get(row["role"], {"x": "X", "y": "Y", "z": "Z"}) + item["dimension_evidence"] = { + axis: { + "value": float(value), + "source": f"尺寸表专用转录{axis_labels[axis]}:{row['source_text']}", + "confidence": row["confidence"], + } + for axis, value in row["dims"].items() + } + item["confidence"] = max(float(item.get("confidence") or 0.0), row["confidence"]) + if len(matches) == 1: + matches[0]["quantity"] = row["quantity"] + elif len(matches) == row["quantity"]: + for item in matches: + item["quantity"] = 1 + x, y, z = row["dims"]["x"], row["dims"]["y"], row["dims"]["z"] + per_piece_area = 2.0 * (x * y + x * z + y * z) / 1_000_000 + for item in matches: + item["coating_area_m2"] = per_piece_area * max(1, int(item.get("quantity") or 1)) + components, rejected_facades = self._remove_schedule_conflicting_facades( + components, + schedule_rows, + updated.get("overall_dimensions_mm") or {}, + ) + if rejected_facades: + previous_rejections = list(updated.get("rejected_visual_components") or []) + updated["rejected_visual_components"] = previous_rejections + rejected_facades + updated["components"] = components + hardware_by_name: dict[str, dict] = {} + for raw in (schedule.get("hardware") or [])[:30]: + if not isinstance(raw, dict): + continue + name = str(raw.get("name") or "").strip() + quantity = max(0, min(200, int(float(raw.get("quantity") or 0)))) + confidence = min(1.0, max(0.0, float(raw.get("confidence") or schedule_confidence))) + if not name or quantity <= 0 or confidence < 0.80: + continue + lower = name.lower() + chinese_name = "铰链" if "hinge" in lower else ("拉手" if any(token in lower for token in ("knob", "handle")) else name) + normalized_hardware = { + "name": chinese_name, "quantity": quantity, + "evidence": f"尺寸表专用转录:{str(raw.get('source_text') or name)[:160]}", + "confidence": confidence, + } + current = hardware_by_name.get(chinese_name) + if not current or confidence > float(current.get("confidence") or 0.0) or quantity > int(current.get("quantity") or 0): + hardware_by_name[chinese_name] = normalized_hardware + hardware = list(hardware_by_name.values()) + if hardware: + updated["recognized_hardware"] = hardware + updated["door_count"] = self._door_panel_count(components) + updated["drawer_count"] = max(int(updated.get("drawer_count") or 0), self._drawer_front_count(components)) + door_rows = [row for row in schedule_rows if row["role"] == "door"] + if door_rows: + door = door_rows[0] + updated["door_dimensions_mm"] = { + "leaf_widths": [float(door["dims"]["x"])] * int(door["quantity"]), + "leaf_height": float(door["dims"]["z"]), + "source": f"尺寸表专用转录:{door['source_text']}", + "confidence": float(door["confidence"]), + } + updated["dimension_schedule"] = { + "table_present": True, + "confidence": schedule_confidence, + "rows": schedule_rows, + "hardware": hardware, + } + if defer_geometry: + # Apply the current user's product envelope before geometrical + # repair. Clamping a neighbouring-SKU schedule against the old + # envelope destroys source dimensions before that contract arrives. + return updated + components = self._canonicalize_component_geometry( + components, + updated.get("overall_dimensions_mm") or {}, + has_doors=bool(updated.get("has_doors")), + ) + updated["components"] = self._canonicalize_simple_cabinet_origins( + components, + updated.get("overall_dimensions_mm") or {}, + has_doors=bool(updated.get("has_doors")), + door_mechanism=str(updated.get("door_mechanism") or ""), + ) + updated["structure_questions"] = self._critical_structure_questions(updated) + updated["process_evidence"] = self._process_evidence(updated) + return updated + + def _normalize_analysis(self, analysis: dict) -> dict: + if not isinstance(analysis, dict): + raise SolidWoodCostError("家具读图结果格式不正确") + overall_raw = analysis.get("overall_dimensions_mm") or {} + overall = {axis: max(1.0, self._float(overall_raw.get(axis), 1.0) or 1.0) for axis in ("x", "y", "z")} + raw_dimension_evidence = analysis.get("dimension_evidence") or {} + dimension_axis_corrections: list[dict] = [] + if isinstance(raw_dimension_evidence, dict): + x_evidence = raw_dimension_evidence.get("x") + y_evidence = raw_dimension_evidence.get("y") + x_evidence = x_evidence if isinstance(x_evidence, dict) else {} + y_evidence = y_evidence if isinstance(y_evidence, dict) else {} + x_source = str(x_evidence.get("source") or "").lower() + y_source = str(y_evidence.get("source") or "").lower() + x_confidence = self._float(x_evidence.get("confidence"), 0.0) or 0.0 + y_confidence = self._float(y_evidence.get("confidence"), 0.0) or 0.0 + x_is_depth = bool(re.search(r"(?:整体)?深|depth", x_source)) + y_is_width = bool(re.search(r"(?:整体)?宽|width", y_source)) + # 产品坐标契约固定为 X=宽、Y=深。多模态模型偶尔把图片坐标轴 + # 当作产品语义轴输出;证据名称明确且置信度足够时,在任何CAD计算 + # 之前修正整体轴,不能再让错误外形进入部件布局和包装计算。 + if x_is_depth and y_is_width and x_confidence >= 0.60 and y_confidence >= 0.60: + original_x, original_y = float(overall["x"]), float(overall["y"]) + overall["x"], overall["y"] = original_y, original_x + raw_dimension_evidence = dict(raw_dimension_evidence) + raw_dimension_evidence["x"] = dict(y_evidence) + raw_dimension_evidence["y"] = dict(x_evidence) + dimension_axis_corrections.append( + { + "rule": "product_semantic_axes_x_width_y_depth", + "from_mm": {"x": original_x, "y": original_y}, + "to_mm": {"x": float(overall["x"]), "y": float(overall["y"])}, + "evidence": [str(x_evidence.get("source") or ""), str(y_evidence.get("source") or "")], + } + ) + components: list[dict] = [] + recognized_hardware: list[dict] = [] + seen: set[str] = set() + for idx, raw in enumerate((analysis.get("components") or [])[:120], 1): + if not isinstance(raw, dict): + continue + shape = str(raw.get("shape") or "box").lower() + if shape == "hardware": + recognized_hardware.append( + { + "name": str(raw.get("name") or "五金")[:80], + "quantity": max(1, min(200, int(self._float(raw.get("quantity"), 1) or 1))), + "evidence": str(raw.get("evidence") or (raw.get("machining") or {}).get("evidence") or "")[:200], + "confidence": min(1.0, max(0.0, self._float(raw.get("confidence"), 0.5) or 0.5)), + } + ) + continue + dims_raw = raw.get("dimensions_mm") or {} + dims = {axis: max(1.0, self._float(dims_raw.get(axis), 1.0) or 1.0) for axis in ("x", "y", "z")} + part_id = re.sub(r"[^A-Z0-9_-]+", "-", str(raw.get("part_id") or f"PART-{idx:03d}").upper()).strip("-") + if not part_id or part_id in seen: + part_id = f"PART-{idx:03d}" + seen.add(part_id) + quantity = max(1, min(50, int(self._float(raw.get("quantity"), 1) or 1))) + origins: list[dict] = [] + for origin in (raw.get("origins_mm") or [])[:quantity]: + if isinstance(origin, dict): + origins.append({axis: self._float(origin.get(axis), 0.0) or 0.0 for axis in ("x", "y", "z")}) + raw_component_evidence = raw.get("dimension_evidence") or {} + component_evidence: dict[str, dict] = {} + for axis in ("x", "y", "z"): + axis_evidence = raw_component_evidence.get(axis) if isinstance(raw_component_evidence, dict) else None + axis_evidence = axis_evidence if isinstance(axis_evidence, dict) else {} + source = str(axis_evidence.get("source") or "未提供逐件尺寸证据")[:160] + confidence = min( + 1.0, + max(0.0, self._float(axis_evidence.get("confidence"), 0.0) or 0.0), + ) + # “估算/反推”不得被模型自行标为高置信尺寸。 + if any(token in source for token in ("估算", "推测", "反推", "常规", "假设", "未标注")): + confidence = min(confidence, 0.55) + component_evidence[axis] = { + "value": float(dims[axis]), + "source": source, + "confidence": confidence, + } + if axis_evidence.get("dimension_kind") in {"width", "depth", "height", "thickness"}: + component_evidence[axis]["dimension_kind"] = axis_evidence["dimension_kind"] + components.append( + { + "part_id": part_id, + "name": str(raw.get("name") or f"部件{idx}")[:80], + "quantity": quantity, + "shape": "box", + "dimensions_mm": dims, + "dimension_evidence": component_evidence, + "origins_mm": origins, + "material_group": str(raw.get("material_group") or "主体结构")[:40], + "component_role": str(raw.get("component_role") or "")[:40], + "source_structure_id": str(raw.get("source_structure_id") or "")[:40], + "assembly_group": str(raw.get("assembly_group") or "")[:40], + "structure_evidence": str(raw.get("structure_evidence") or "")[:240], + "recommended_species": str(raw.get("recommended_species") or "白橡木")[:60], + "coating_area_m2": max(0.0, self._float(raw.get("coating_area_m2"), 0.0) or 0.0), + "edge_banding": { + "exposed_length_m": max( + 0.0, + self._float((raw.get("edge_banding") or {}).get("exposed_length_m"), 0.0) or 0.0, + ), + "confirmed": bool((raw.get("edge_banding") or {}).get("confirmed")), + "includes_adhesive": bool((raw.get("edge_banding") or {}).get("includes_adhesive")), + }, + "machining": { + "holes": max(0, int(self._float((raw.get("machining") or {}).get("holes"), 0) or 0)), + "slots": max(0, int(self._float((raw.get("machining") or {}).get("slots"), 0) or 0)), + "slot_length_m": max(0.0, self._float((raw.get("machining") or {}).get("slot_length_m"), 0.0) or 0.0), + "mortise_tenon": max(0, int(self._float((raw.get("machining") or {}).get("mortise_tenon"), 0) or 0)), + "edge_profile_m": max(0.0, self._float((raw.get("machining") or {}).get("edge_profile_m"), 0.0) or 0.0), + "carving_area_m2": max(0.0, self._float((raw.get("machining") or {}).get("carving_area_m2"), 0.0) or 0.0), + "carving_path_m": max(0.0, self._float((raw.get("machining") or {}).get("carving_path_m"), 0.0) or 0.0), + "evidence": str((raw.get("machining") or {}).get("evidence") or "")[:200], + "confidence": min(1.0, max(0.0, self._float((raw.get("machining") or {}).get("confidence"), 0.0) or 0.0)), + }, + "confidence": min(1.0, max(0.0, self._float(raw.get("confidence"), 0.6) or 0.6)), + } + ) + if not components: + raise SolidWoodCostError("没有从图片中识别出可加工的独立部件") + assumptions = [str(x)[:240] for x in (analysis.get("assumptions") or []) if str(x).strip()][:12] + uncertainties = [str(x)[:240] for x in (analysis.get("uncertainties") or []) if str(x).strip()][:12] + door_dimensions = self._normalize_door_dimensions(analysis.get("door_dimensions_mm")) + components = self._canonicalize_component_geometry( + components, + overall, + has_doors=bool(analysis.get("has_doors")), + ) + components = self._promote_dimension_backed_doors( + components, + door_dimensions, + has_doors=bool(analysis.get("has_doors")), + ) + door_mechanism = self._infer_door_mechanism(analysis, components, recognized_hardware) + if door_mechanism == "sliding": + recognized_hardware = [ + item for item in recognized_hardware if not any(token in str(item.get("name") or "") for token in ("铰链", "hinge")) + ] + if not any( + any(token in str(item.get("name") or "").lower() for token in ("轨", "滑轮", "track", "roller")) + for item in recognized_hardware + ): + recognized_hardware.append( + { + "name": "移门上下轨道与滑轮套件", + "quantity": max(1, math.ceil(max(1, int(self._float(analysis.get("door_count"), 1) or 1)) / 2)), + "evidence": "柜门开启方式已识别为推拉/移门;属于成立所必需的五金", + "confidence": 0.95, + } + ) + components = self._apply_confirmed_door_dimensions(components, door_dimensions) + components = self._remove_duplicate_door_facades(components) + components = self._canonicalize_simple_cabinet_origins( + components, + overall, + has_doors=bool(analysis.get("has_doors")), + door_mechanism=door_mechanism, + ) + declared_drawers = max(0, int(self._float(analysis.get("drawer_count"), 0) or 0)) + drawer_layout = self._drawer_layout(analysis) + components = self._normalize_drawer_composites(components, declared_drawers) + components = self._limit_drawer_fronts(components, declared_drawers) + raw_depth_evidence = raw_dimension_evidence.get("y") if isinstance(raw_dimension_evidence, dict) else None + depth_is_confirmed = not raw_dimension_evidence or ( + isinstance(raw_depth_evidence, dict) + and float(self._float(raw_depth_evidence.get("value"), 0.0) or 0.0) > 0 + and float(self._float(raw_depth_evidence.get("confidence"), 0.0) or 0.0) >= 0.60 + ) + if depth_is_confirmed: + components = self._ensure_complete_drawer_boxes(components, overall, declared_drawers, drawer_layout=drawer_layout) + + dimension_evidence: dict[str, dict] = {} + for axis in ("x", "y", "z"): + raw_axis = raw_dimension_evidence.get(axis) if isinstance(raw_dimension_evidence, dict) else None + raw_axis = raw_axis if isinstance(raw_axis, dict) else {} + evidence_value = self._float(raw_axis.get("value"), 0.0) or 0.0 + dimension_evidence[axis] = { + "value": evidence_value if evidence_value > 0 else float(overall[axis]), + "source": str(raw_axis.get("source") or ("模型整体尺寸" if not raw_dimension_evidence else "未标注"))[:160], + "confidence": min( + 1.0, + max(0.0, self._float(raw_axis.get("confidence"), 0.75 if not raw_dimension_evidence else 0.0) or 0.0), + ), + } + + normalized = { + "product_name": str(analysis.get("product_name") or "实木家具")[:100], + "product_type": str(analysis.get("product_type") or "家具")[:80], + "overall_dimensions_mm": overall, + "has_doors": bool(analysis.get("has_doors")), + "has_back_panel": analysis.get("has_back_panel") if isinstance(analysis.get("has_back_panel"), bool) else None, + "back_panel_confidence": min(1.0, max(0.0, self._float(analysis.get("back_panel_confidence"), 0.0) or 0.0)), + "back_panel_evidence": str(analysis.get("back_panel_evidence") or "")[:240], + "door_mechanism": door_mechanism, + "door_count": self._door_panel_count(components), + "drawer_count": declared_drawers or self._drawer_front_count(components), + "drawer_layout": drawer_layout, + "drawer_layout_confidence": min(1.0, max(0.0, self._float(analysis.get("drawer_layout_confidence"), 0.0) or 0.0)), + "drawer_layout_evidence": str(analysis.get("drawer_layout_evidence") or "")[:240], + "door_dimensions_mm": door_dimensions, + "dimension_evidence": dimension_evidence, + "components": components, + "recognized_hardware": recognized_hardware[:30], + "uncertainties": uncertainties, + "assumptions": assumptions, + "dimension_axis_corrections": dimension_axis_corrections, + } + raw_segmentation = analysis.get("side_panel_segmentation") + raw_segmentation = raw_segmentation if isinstance(raw_segmentation, dict) else {} + segment_count = max(0, min(8, int(self._float(raw_segmentation.get("segments_per_side"), 0) or 0))) + segment_confidence = min( + 1.0, + max(0.0, self._float(raw_segmentation.get("confidence"), 0.0) or 0.0), + ) + segment_heights = [ + float(value) + for value in (raw_segmentation.get("segment_heights_mm") or [])[:8] + if (self._float(value, 0.0) or 0.0) > 0 + ] + if ( + segment_count > 1 + and len(segment_heights) == segment_count + and segment_confidence >= 0.85 + and str(raw_segmentation.get("evidence") or "").strip() + ): + overall_height = float(overall.get("z") or 0.0) + raw_height_sum = sum(segment_heights) + if overall_height > 0 and raw_height_sum * 10.0 <= overall_height * 1.05 and raw_height_sum * 10.0 >= overall_height * 0.80: + segment_heights = [value * 10.0 for value in segment_heights] + effective_sum = sum(segment_heights) + height_plausible = overall_height <= 0 or ( + effective_sum <= overall_height * 1.05 + and effective_sum >= overall_height * 0.80 + ) + if height_plausible: + depths = [ + float(value) + for value in (raw_segmentation.get("segment_depths_mm") or [])[:segment_count] + if (self._float(value, 0.0) or 0.0) > 0 + ] + overall_depth = float(overall.get("y") or 0.0) + lower_depth = depths[0] if depths else overall_depth + upper_depths = depths[1:] if len(depths) == segment_count else [] + upper_depth = statistics.median(upper_depths) if upper_depths else overall_depth + lower_depth = min(max(1.0, lower_depth), max(1.0, overall_depth)) + upper_depth = min(max(1.0, upper_depth), max(1.0, overall_depth)) + contract = normalized.setdefault("user_topology_constraints", {}) + contract.update( + { + "source": "AI多视角结构识别", + "source_text": str(raw_segmentation.get("evidence") or "")[:240], + "side_segments_per_side": segment_count, + "side_segment_heights_mm": [round(value, 3) for value in segment_heights], + "side_segment_lower_depth_mm": round(lower_depth, 3), + "side_segment_upper_depth_mm": round(upper_depth, 3), + "ai_review_confidence": {"side_segments_per_side": segment_confidence}, + } + ) + normalized["side_panel_segmentation"] = { + "segments_per_side": segment_count, + "segment_heights_mm": [round(value, 3) for value in segment_heights], + "segment_depths_mm": [round(value, 3) for value in depths], + "evidence": str(raw_segmentation.get("evidence") or "")[:240], + "confidence": segment_confidence, + "source": "multiview_initial_analysis", + } + normalized = self._apply_topology_geometry_contract(normalized) + normalized["structure_questions"] = self._critical_structure_questions(normalized) + normalized["process_evidence"] = self._process_evidence(normalized) + return normalized + + @staticmethod + def _process_evidence(analysis: dict) -> dict: + components = analysis.get("components") or [] + totals = { + "holes": 0, + "slots": 0, + "slot_length_m": 0.0, + "mortise_tenon": 0, + "edge_profile_m": 0.0, + "carving_area_m2": 0.0, + "carving_path_m": 0.0, + } + evidence: list[str] = [] + confidences: list[float] = [] + for item in components: + machining = item.get("machining") or {} + quantity = max(1, int(item.get("quantity") or 1)) + for key in ("holes", "slots", "mortise_tenon"): + totals[key] += int(machining.get(key) or 0) * quantity + for key in ("edge_profile_m", "carving_area_m2", "carving_path_m"): + totals[key] += float(machining.get(key) or 0.0) * quantity + totals["slot_length_m"] += float(machining.get("slot_length_m") or 0.0) * quantity + if str(machining.get("evidence") or "").strip(): + evidence.append(f"{item.get('name')}: {str(machining['evidence']).strip()}") + if any(float(machining.get(key) or 0) > 0 for key in totals): + confidences.append(float(machining.get("confidence") or item.get("confidence") or 0.0)) + has_features = any(float(value) > 0 for value in totals.values()) + confidence = min(confidences) if confidences else (1.0 if not has_features else 0.0) + return { + "totals": totals, + "has_features": has_features, + "has_carving": totals["carving_area_m2"] > 0 or totals["carving_path_m"] > 0, + "evidence": evidence[:12], + "confidence": confidence, + "requires_confirmation": bool(has_features and confidence < 0.65), + } + + @staticmethod + def _component_key(item: dict) -> str: + return f"{item.get('name') or ''} {item.get('material_group') or ''}".lower() + + @classmethod + def _is_shelf_panel(cls, item: dict) -> bool: + key = cls._component_key(item) + return ( + any(token in key for token in ("层板", "搁板", "层隔板", "shelf")) + and not any(token in key for token in ("抽屉", "drawer")) + ) + + @classmethod + def _is_leg_component(cls, item: dict) -> bool: + """Return true for load-bearing legs, never a plinth/toe-kick board.""" + key = cls._component_key(item) + if any(token in key for token in ("踢脚", "地脚线", "toe kick", "toe-kick", "plinth")): + return False + if any(token in key for token in ("抽屉", "drawer")): + return False + return any(token in key for token in ("柜腿", "支腿", "桌腿", "床腿", "腿", "脚柱", "脚", "leg")) + + @classmethod + def _canonicalize_component_geometry( + cls, + components: list[dict], + overall: dict, + *, + has_doors: bool, + strict_envelope: bool = False, + ) -> list[dict]: + """Normalize vision dimensions to the product X/Y/Z coordinate contract. + + Vision models sometimes preserve the three numeric dimensions but swap + their axes according to a board's local orientation. A CAD assembly + cannot use that convention: X is always product width, Y depth and Z + height. This pass uses the model's own dimension evidence labels and + conservative cabinet geometry; inferred values remain low-confidence. + """ + overall_x = max(1.0, float(overall.get("x") or 1.0)) + overall_y = max(1.0, float(overall.get("y") or 1.0)) + overall_z = max(1.0, float(overall.get("z") or 1.0)) + + def evidence_for(item: dict, token: str) -> tuple[float, dict] | None: + dims = item.get("dimensions_mm") or {} + evidence = item.get("dimension_evidence") or {} + expected_kind = {"宽": "width", "深": "depth", "高": "height", "厚": "thickness"}[token] + for axis in ("x", "y", "z"): + axis_evidence = evidence.get(axis) or {} + if axis_evidence.get("dimension_kind") == expected_kind: + return float(dims.get(axis) or axis_evidence.get("value") or 0.0), dict(axis_evidence) + for axis in ("x", "y", "z"): + axis_evidence = evidence.get(axis) if isinstance(evidence, dict) else None + axis_evidence = axis_evidence if isinstance(axis_evidence, dict) else {} + # First-pass model prose may mention a subtraction operand: + # "总宽减两侧板厚" describes WIDTH, not THICKNESS. Matching any + # occurrence of 厚 would copy the width into panel thickness + # before structured dimension_kind has ever been assigned. + source = str(axis_evidence.get("source") or "").lower() + semantic = re.search(r"宽(?:度)?|width|深(?:度)?|depth|高(?:度)?|height|(?:板)?厚(?:度)?|thickness", source) + described_kind = None + if semantic: + term = semantic.group() + described_kind = ( + "width" if "宽" in term or term == "width" + else "depth" if "深" in term or term == "depth" + else "height" if "高" in term or term == "height" + else "thickness" + ) + if not axis_evidence.get("dimension_kind") and described_kind == expected_kind: + return float(dims.get(axis) or axis_evidence.get("value") or 0.0), dict(axis_evidence) + return None + + def evidence(value: float, source: str, confidence: float) -> dict: + return {"value": float(value), "source": source[:160], "confidence": min(1.0, max(0.0, confidence))} + + def set_geometry(item: dict, dims: dict[str, float], mapped: dict[str, dict]) -> None: + item["dimensions_mm"] = {axis: max(1.0, float(dims[axis])) for axis in ("x", "y", "z")} + role = cls._component_schedule_role(item) + semantic = ( + {"x": "width", "y": "depth", "z": "thickness"} if role in {"top", "bottom", "shelf", "drawer_bottom"} + else {"x": "thickness", "y": "depth", "z": "height"} if role in {"side", "drawer_left", "drawer_right"} + else {"x": "width", "y": "thickness", "z": "height"} if role in {"door", "back", "drawer_front", "drawer_back"} + else {"x": "width", "y": "depth", "z": "height"} + ) + item["dimension_evidence"] = { + axis: { + "value": float(item["dimensions_mm"][axis]), + "source": str((mapped.get(axis) or {}).get("source") or "几何规范化")[:160], + "confidence": min(1.0, max(0.0, float((mapped.get(axis) or {}).get("confidence") or 0.0))), + "dimension_kind": semantic[axis], + } + for axis in ("x", "y", "z") + } + x, y, z = (item["dimensions_mm"][axis] for axis in ("x", "y", "z")) + quantity = max(1, int(item.get("quantity") or 1)) + item["coating_area_m2"] = 2.0 * (x * y + x * z + y * z) * quantity / 1_000_000 + item["confidence"] = min( + float(item.get("confidence") or 0.0), + min(float(item["dimension_evidence"][axis]["confidence"]) for axis in ("x", "y", "z")), + ) + + for item in components: + key = cls._component_key(item) + dims = {axis: float((item.get("dimensions_mm") or {}).get(axis) or 1.0) for axis in ("x", "y", "z")} + existing = item.get("dimension_evidence") or {} + width = evidence_for(item, "宽") + depth = evidence_for(item, "深") + height = evidence_for(item, "高") + thickness = evidence_for(item, "厚") + ordered = sorted(dims.values()) + smallest, middle, largest = ordered[0], ordered[1], ordered[2] + is_drawer = "抽屉" in key or "drawer" in key + is_horizontal = ( + not is_drawer + and ( + cls._is_shelf_panel(item) + or any( + token in key + for token in ( + "顶板", "桌面", "台面", "top panel", "tabletop", "desktop", + "柜体底板", "柜底板", "bottom panel", "横隔板", "横向隔板", + ) + ) + ) + ) + is_side = ( + not is_drawer + and any(token in key for token in ("侧板", "side panel", "left side", "right side", "竖隔板", "竖向隔板", "中隔板")) + ) + is_facade = any(token in key for token in ("柜门", "门板", "前挡板", "door panel", "back panel", "backboard", "背板")) + is_leg = cls._is_leg_component(item) + + if cls._is_drawer_front(item): + # Drawer-front annotations are especially prone to a unit/axis + # error: a label such as ``15CM`` is sometimes returned as a + # 15 mm "drawer depth" while the neighbouring 58CM label is + # correctly converted to 580 mm. A front is always expressed + # as product-width X, panel-thickness Y and face-height Z. + new_x = float(width[0] if width else largest) + new_y = float(thickness[0] if thickness else smallest) + new_z = float(height[0] if height else middle) + height_source = dict(height[1]) if height else {} + if not height and new_z <= max(40.0, new_y * 2.5): + non_thickness_evidence: list[tuple[float, dict]] = [] + for axis in ("x", "y", "z"): + axis_evidence = existing.get(axis) if isinstance(existing, dict) else None + axis_evidence = axis_evidence if isinstance(axis_evidence, dict) else {} + source = str(axis_evidence.get("source") or "") + value = float(dims.get(axis) or axis_evidence.get("value") or 0.0) + if "抽屉" in source and "板厚" not in source and 10.0 <= value <= 40.0: + non_thickness_evidence.append((value, axis_evidence)) + if non_thickness_evidence and new_x >= 100.0: + raw_value, raw_evidence = max(non_thickness_evidence, key=lambda pair: pair[0]) + scaled = raw_value * 10.0 + if 80.0 <= scaled <= min(400.0, overall_z * 0.35): + new_z = scaled + height_source = evidence( + new_z, + f"尺寸图抽面标注{raw_value:g}按cm转换为mm(原识别轴标签为{str(raw_evidence.get('source') or '未知')})", + min(0.90, float(raw_evidence.get("confidence") or 0.0)), + ) + mapped = { + "x": dict(width[1]) if width else evidence(new_x, "按抽面长边规范化为正面宽度", 0.55), + "y": dict(thickness[1]) if thickness else evidence(new_y, "按最小尺寸识别抽面板厚", 0.55), + "z": height_source or evidence(new_z, "按抽面另一面边识别高度", 0.55), + } + set_geometry(item, {"x": new_x, "y": new_y, "z": new_z}, mapped) + elif is_horizontal: + new_x = float(width[0] if width else largest) + new_z = float(thickness[0] if thickness else smallest) + new_y = float(depth[0] if depth else middle) + mapped = { + "x": dict(width[1]) if width else evidence(new_x, "按板件长边规范化为产品宽度", 0.55), + "z": dict(thickness[1]) if thickness else evidence(new_z, "按最小尺寸识别板厚", 0.55), + "y": dict(depth[1]) if depth else evidence(new_y, "按板件第二长边识别深度", 0.55), + } + if strict_envelope and new_x > overall_x * 1.05: + new_x = overall_x + mapped["x"] = evidence( + new_x, + "原识别板件宽度超过用户确认的产品整体宽度,按产品外包围硬约束修正", + 0.65, + ) + if new_y > overall_y * 1.05 or (not depth and cls._is_shelf_panel(item)): + if cls._is_shelf_panel(item) and not depth: + inset = min(max(new_z, 0.0), overall_y * 0.15) + new_y = max(1.0, overall_y - inset) + mapped["y"] = evidence(new_y, "根据整体深度减主体板厚反推层板深度,等待CAD复核", 0.55) + elif strict_envelope: + new_y = overall_y + mapped["y"] = evidence( + new_y, + "原识别板件深度超过用户确认的产品整体深度,按产品外包围硬约束修正", + 0.65, + ) + else: + inset = min(max(new_z, 0.0), overall_y * 0.15) + new_y = max(1.0, overall_y - inset) + mapped["y"] = evidence(new_y, "根据整体深度减主体板厚反推板件深度,等待CAD复核", 0.55) + set_geometry(item, {"x": new_x, "y": new_y, "z": new_z}, mapped) + elif is_side: + new_x = float(thickness[0] if thickness else smallest) + new_y = float(depth[0] if depth else min(middle, overall_y)) + new_z = float(height[0] if height else largest) + mapped = { + "x": dict(thickness[1]) if thickness else evidence(new_x, "按最小尺寸识别侧板厚度", 0.55), + "y": dict(depth[1]) if depth else evidence(new_y, "按柜体深度规范化侧板深度", 0.55), + "z": dict(height[1]) if height else evidence(new_z, "按侧板长边规范化为产品高度", 0.55), + } + if new_y > overall_y * 1.05: + # A side panel cannot be deeper than the product envelope. + # The observed 300mm-product/480mm-side failure came from + # copying product width into the side depth axis. + new_y = overall_y + mapped["y"] = evidence( + new_y, + "原识别侧板深度超过产品整体深度,按整体深度硬约束修正并等待CAD逐图复核", + 0.55, + ) + if strict_envelope and new_z > overall_z * 1.05: + new_z = overall_z + mapped["z"] = evidence( + new_z, + "原识别侧板高度超过用户确认的产品整体高度,按产品外包围硬约束修正", + 0.65, + ) + set_geometry(item, {"x": new_x, "y": new_y, "z": new_z}, mapped) + elif is_facade: + new_x = float(width[0] if width else largest) + new_y = float(thickness[0] if thickness else smallest) + new_z = float(height[0] if height else middle) + mapped = { + "x": dict(width[1]) if width else evidence(new_x, "按正面板长边规范化为产品宽度", 0.55), + "y": dict(thickness[1]) if thickness else evidence(new_y, "按最小尺寸识别正面板厚度", 0.55), + "z": dict(height[1]) if height else evidence(new_z, "按正面板另一长边规范化为产品高度", 0.55), + } + if strict_envelope and new_x > overall_x * 1.05: + new_x = overall_x + mapped["x"] = evidence( + new_x, + "原识别正背面板宽度超过用户确认的产品整体宽度,按产品外包围硬约束修正", + 0.65, + ) + if strict_envelope and new_z > overall_z * 1.05: + new_z = overall_z + mapped["z"] = evidence( + new_z, + "原识别正背面板高度超过用户确认的产品整体高度,按产品外包围硬约束修正", + 0.65, + ) + set_geometry(item, {"x": new_x, "y": new_y, "z": new_z}, mapped) + elif is_leg: + new_x = float(width[0] if width else smallest) + new_y = float(depth[0] if depth else middle) + new_z = float(height[0] if height else largest) + mapped = { + "x": dict(width[1]) if width else evidence(new_x, "按腿部截面短边识别宽度", 0.55), + "y": dict(depth[1]) if depth else evidence(new_y, "按腿部截面另一短边识别深度", 0.55), + "z": dict(height[1]) if height else evidence(new_z, "按腿部长边识别高度", 0.55), + } + if strict_envelope and new_z > overall_z * 1.05: + new_z = overall_z + mapped["z"] = evidence( + new_z, + "原识别腿高超过用户确认的产品整体高度,按产品外包围硬约束修正", + 0.65, + ) + set_geometry(item, {"x": new_x, "y": new_y, "z": new_z}, mapped) + + top = next( + ( + item for item in components + if any( + token in cls._component_key(item) + for token in ("顶板", "桌面", "台面", "top panel", "tabletop", "desktop") + ) + ), + None, + ) + bottom = next((item for item in components if any(token in cls._component_key(item) for token in ("柜体底板", "柜底板", "bottom panel", "底板")) and "抽屉" not in cls._component_key(item)), None) + side = next((item for item in components if any(token in cls._component_key(item) for token in ("侧板", "side panel")) and "抽屉" not in cls._component_key(item)), None) + legs = [item for item in components if cls._is_leg_component(item)] + if top and bottom and side and legs: + residual = overall_z - float(top["dimensions_mm"]["z"]) - float(bottom["dimensions_mm"]["z"]) - float(side["dimensions_mm"]["z"]) + if 5.0 <= residual <= overall_z * 0.45: + for item in legs: + current = float(item["dimensions_mm"]["z"]) + if abs(current - residual) > max(5.0, residual * 0.20): + mapped = dict(item.get("dimension_evidence") or {}) + mapped["z"] = evidence(residual, "整体高度减顶板、底板和侧板高度反推腿高,等待CAD复核", 0.55) + set_geometry( + item, + {"x": item["dimensions_mm"]["x"], "y": item["dimensions_mm"]["y"], "z": residual}, + mapped, + ) + # A back panel cannot be taller than the closed carcass cavity. When + # top + bottom + side + legs closes the overall height exactly, that + # dimension chain is stronger evidence than an isolated OCR/model + # height for the back panel. This repairs the observed 760-vs-720 mm + # error without applying a generic percentage guess. + if top and bottom and side: + side_height = float(side["dimensions_mm"]["z"]) + leg_height = max((float(item["dimensions_mm"]["z"]) for item in legs), default=0.0) + closed_height = ( + float(top["dimensions_mm"]["z"]) + + float(bottom["dimensions_mm"]["z"]) + + side_height + + leg_height + ) + if abs(closed_height - overall_z) <= max(4.0, overall_z * 0.01): + for item in components: + if not cls._is_back_panel(item): + continue + current_height = float(item["dimensions_mm"]["z"]) + if current_height > side_height + max(4.0, side_height * 0.01): + mapped = dict(item.get("dimension_evidence") or {}) + mapped["z"] = evidence( + side_height, + "顶板+底板+侧板+腿高与整体高闭合,背板高按侧板高纠正", + 0.90, + ) + set_geometry( + item, + {"x": item["dimensions_mm"]["x"], "y": item["dimensions_mm"]["y"], "z": side_height}, + mapped, + ) + return components + + @classmethod + def _promote_dimension_backed_doors( + cls, + components: list[dict], + door_dimensions: dict, + *, + has_doors: bool, + ) -> list[dict]: + """Treat front facades backed by door evidence as doors, not rails.""" + expected = len(door_dimensions.get("leaf_widths") or []) + if not has_doors and expected <= 0: + return components + explicit_door_count = sum( + max(1, int(item.get("quantity") or 1)) + for item in components + if any( + token in cls._component_key(item) + for token in ("柜门", "门板", "推拉门", "移门", "door panel", "sliding door") + ) + ) + door_index = 0 + for item in components: + key = cls._component_key(item) + if any(token in key for token in ("柜门", "门板", "推拉门", "移门", "door panel", "sliding door")): + door_index += max(1, int(item.get("quantity") or 1)) + continue + evidence_text = json.dumps(item.get("dimension_evidence") or {}, ensure_ascii=False) + evidence_backed = "门片" in evidence_text or "door leaf" in evidence_text.lower() + generic_facade = any(token in key for token in ("前挡板", "front panel", "facade")) + if not evidence_backed and (explicit_door_count > 0 or not generic_facade): + continue + if expected and door_index >= expected: + continue + side = "左" if any(token in key for token in ("左", "left")) else ( + "右" if any(token in key for token in ("右", "right")) else str(door_index + 1) + ) + item["name"] = f"柜门({side})" + item["material_group"] = "柜门" + item["role_normalization"] = "尺寸证据含门片,已从前挡板纠正为柜门" + door_index += max(1, int(item.get("quantity") or 1)) + return components + + @classmethod + def _canonicalize_simple_cabinet_origins( + cls, + components: list[dict], + overall: dict, + *, + has_doors: bool, + door_mechanism: str = "", + ) -> list[dict]: + """Lay out a complete simple cabinet in product coordinates. + + This is only used when the minimum cabinet shell is present. Positions + remain explicitly marked as CAD estimates so source images can override + them in a later engineering pass. + """ + width = float(overall.get("x") or 0.0) + depth = float(overall.get("y") or 0.0) + height = float(overall.get("z") or 0.0) + top = next( + ( + item for item in components + if any( + token in cls._component_key(item) + for token in ("顶板", "桌面", "台面", "top panel", "table top", "tabletop", "desktop") + ) + ), + None, + ) + bottom = next((item for item in components if any(token in cls._component_key(item) for token in ("柜体底板", "柜底板", "bottom panel", "底板")) and "抽屉" not in cls._component_key(item)), None) + sides = [item for item in components if any(token in cls._component_key(item) for token in ("侧板", "side panel")) and "抽屉" not in cls._component_key(item)] + if not top or not bottom or sum(max(1, int(item.get("quantity") or 1)) for item in sides) < 2: + return components + legs = [item for item in components if cls._is_leg_component(item)] + leg_height = max((float(item["dimensions_mm"]["z"]) for item in legs), default=0.0) + plinth_height = max( + ( + float((item.get("dimensions_mm") or {}).get("z") or 0.0) + for item in components + if any(token in cls._component_key(item) for token in ("踢脚", "toe kick", "toe-kick", "plinth")) + ), + default=0.0, + ) + support_height = max(leg_height, plinth_height) + bottom_z = support_height + side_height = max(float(item["dimensions_mm"]["z"]) for item in sides) + nested_side_fit = ( + support_height + + float(bottom["dimensions_mm"]["z"]) + + side_height + + float(top["dimensions_mm"]["z"]) + <= height + max(3.0, height * 0.01) + ) + # Some drawings dimension the side between top/bottom; others use a + # full-height side with top/bottom captured between it. Pick the only + # layout that stays inside the confirmed overall envelope. + side_z = bottom_z + float(bottom["dimensions_mm"]["z"]) if nested_side_fit else 0.0 + top_z = max(side_z, height - float(top["dimensions_mm"]["z"])) + top["origins_mm"] = [{"x": 0.0, "y": 0.0, "z": top_z}] + bottom["origins_mm"] = [{"x": 0.0, "y": 0.0, "z": bottom_z}] + side_origins = [ + {"x": 0.0, "y": 0.0, "z": side_z}, + {"x": max(0.0, width - float(sides[0]["dimensions_mm"]["x"])), "y": 0.0, "z": side_z}, + ] + cursor = 0 + for item in sides: + quantity = max(1, int(item.get("quantity") or 1)) + item["origins_mm"] = side_origins[cursor:cursor + quantity] + cursor += quantity + doors = [item for item in components if cls._is_door_panel(item)] + left_side_thickness = max(float(item["dimensions_mm"]["x"]) for item in sides) + opening_left = left_side_thickness + opening_right = max(opening_left, width - left_side_thickness) + door_instance_count = sum(max(1, int(item.get("quantity") or 1)) for item in doors) + total_door_width = sum( + float(item["dimensions_mm"]["x"]) * max(1, int(item.get("quantity") or 1)) + for item in doors + ) + opening_width = max(0.0, opening_right - opening_left) + sliding_overlap = door_mechanism == "sliding" and door_instance_count > 1 and total_door_width > opening_width + door_gap = ( + max(0.0, opening_width - total_door_width) / (door_instance_count + 1) + if door_instance_count and not sliding_overlap else 0.0 + ) + door_x = opening_left + door_gap + door_instance_index = 0 + for item in doors: + quantity = max(1, int(item.get("quantity") or 1)) + item["origins_mm"] = [] + for _ in range(quantity): + door_height = float(item["dimensions_mm"]["z"]) + if sliding_overlap: + door_x = opening_left + ( + max(0.0, opening_width - float(item["dimensions_mm"]["x"])) + * door_instance_index / max(1, door_instance_count - 1) + ) + item["origins_mm"].append( + {"x": door_x, "y": 0.0, "z": side_z + max(0.0, (side_height - door_height) / 2.0)} + ) + if not sliding_overlap: + door_x += float(item["dimensions_mm"]["x"]) + door_gap + door_instance_index += 1 + for item in components: + key = cls._component_key(item) + dims = item.get("dimensions_mm") or {} + quantity = max(1, int(item.get("quantity") or 1)) + if cls._is_back_panel(item): + item["origins_mm"] = [{"x": max(0.0, (width - float(dims["x"])) / 2.0), "y": max(0.0, depth - float(dims["y"])), "z": side_z}] + elif cls._is_shelf_panel(item): + usable_height = max(1.0, top_z - side_z - float(dims["z"])) + item["origins_mm"] = [ + { + "x": max(0.0, (width - float(dims["x"])) / 2.0), + "y": max(0.0, (depth - float(dims["y"])) / 2.0), + "z": side_z + usable_height * (index + 1) / (quantity + 1), + } + for index in range(quantity) + ] + elif item in legs: + leg_width = float(dims["x"]) + leg_depth = float(dims["y"]) + candidates = [ + {"x": 0.0, "y": 0.0, "z": 0.0}, + {"x": max(0.0, width - leg_width), "y": 0.0, "z": 0.0}, + {"x": 0.0, "y": max(0.0, depth - leg_depth), "z": 0.0}, + {"x": max(0.0, width - leg_width), "y": max(0.0, depth - leg_depth), "z": 0.0}, + ] + item["origins_mm"] = candidates[:quantity] + item["origin_source"] = "标准柜体产品坐标装配布局预估,等待CAD与原图复核" + item["origin_confidence"] = 0.55 + return components + + @classmethod + def _apply_user_structure_overrides(cls, analysis: dict, message: str) -> dict: + """Apply explicit, manufacturable topology statements to the BOM. + + This is deliberately narrow. It never invents a part merely because a + product category often has one; it only records topology the user has + explicitly stated. Inferred dimensions remain low confidence and keep + the CAD-estimation gate active. + """ + updated = json.loads(json.dumps(analysis, ensure_ascii=False)) + text = re.sub(r"\s+", "", str(message or "")) + if not text: + return updated + components = list(updated.get("components") or []) + overall = updated.get("overall_dimensions_mm") or {} + width = max(1.0, float(overall.get("x") or 1.0)) + depth = max(1.0, float(overall.get("y") or 1.0)) + height = max(1.0, float(overall.get("z") or 1.0)) + thicknesses = [ + min(float(value) for value in (item.get("dimensions_mm") or {}).values()) + for item in components + if len(item.get("dimensions_mm") or {}) == 3 + and min(float(value) for value in (item.get("dimensions_mm") or {}).values()) <= 40 + ] + thickness = min(30.0, max(2.0, statistics.median(thicknesses) if thicknesses else 18.0)) + contract = dict(updated.get("user_topology_constraints") or {}) + expected = dict(contract.get("expected_counts") or {}) + required = list(contract.get("required_parts") or []) + used_ids = {str(item.get("part_id") or "") for item in components} + + def add_part(part_id: str, name: str, dims: dict[str, float], material_group: str = "主体结构") -> None: + if any(name == str(item.get("name") or "") for item in components): + return + candidate = part_id + while candidate in used_ids: + candidate += "X" + used_ids.add(candidate) + components.append( + { + "part_id": candidate, + "name": name, + "quantity": 1, + "shape": "box", + "dimensions_mm": {axis: max(1.0, float(dims[axis])) for axis in ("x", "y", "z")}, + "dimension_evidence": { + axis: { + "value": max(1.0, float(dims[axis])), + "source": "用户明确存在该部件;尺寸按整体装配关系预估,等待CAD复核", + "confidence": 0.55, + } + for axis in ("x", "y", "z") + }, + "origins_mm": [], + "material_group": material_group, + "recommended_species": "主体板材", + "coating_area_m2": 0.0, + "edge_banding": {"exposed_length_m": 0.0, "confirmed": False, "includes_adhesive": True}, + "machining": { + "holes": 0, "slots": 0, "slot_length_m": 0.0, "mortise_tenon": 0, + "edge_profile_m": 0.0, "carving_area_m2": 0.0, "carving_path_m": 0.0, + "evidence": "用户确认部件拓扑;加工特征等待生产连接方案生成", "confidence": 0.55, + }, + "confidence": 0.55, + } + ) + + no_doors = bool(re.search(r"(?:无|没有|不带|取消|不要)(?:任何)?(?:柜门|门板)|开放(?:式)?(?:书架|柜)", text)) + if no_doors: + components = [item for item in components if not cls._is_door_panel(item)] + if re.search(r"(?:无|没有|不要|不应有)(?:整高)?(?:前挡板|挡板|前面板)|前方(?:无|没有)(?:整高)?板", text): + cleaned_components: list[dict] = [] + for item in components: + key = cls._component_key(item) + dims = item.get("dimensions_mm") or {} + front_profile = any(token in key for token in ("前挡板", "正面轮廓板", "前方轮廓板", "front facade")) + spans_height = max(float(dims.get("x") or 0.0), float(dims.get("z") or 0.0)) >= height * 0.80 + if front_profile and spans_height: + continue + cleaned_components.append(item) + components = cleaned_components + updated["has_doors"] = False + updated["door_mechanism"] = "none" + updated["door_count"] = 0 + updated["door_dimensions_mm"] = {"leaf_widths": [], "leaf_height": 0.0, "source": "用户明确无柜门", "confidence": 1.0} + updated["recognized_hardware"] = [ + item for item in (updated.get("recognized_hardware") or []) + if not any(token in str(item.get("name") or "").lower() for token in ("铰链", "合页", "移门", "滑轮", "轨道", "hinge", "door")) + ] + expected["doors"] = 0 + + if any(token in text for token in ("推拉门", "移门", "滑门")): + updated["has_doors"] = True + updated["door_mechanism"] = "sliding" + updated["recognized_hardware"] = [ + item for item in (updated.get("recognized_hardware") or []) + if not any(token in str(item.get("name") or "").lower() for token in ("铰链", "hinge")) + ] + if not any(any(token in str(item.get("name") or "") for token in ("轨", "滑轮")) for item in updated["recognized_hardware"]): + updated["recognized_hardware"].append( + {"name": "移门上下轨道与滑轮套件", "quantity": 1, "evidence": "用户明确为推拉/移门", "confidence": 1.0} + ) + # A full-height generic front facade cannot coexist with confirmed + # sliding leaves. Keep narrow rails; remove only the facade that + # covers almost the complete product front. + filtered_components: list[dict] = [] + for item in components: + key = cls._component_key(item) + dims = item.get("dimensions_mm") or {} + if "前挡板" in key and not cls._is_door_panel(item): + planar_height = max(float(dims.get("y") or 0.0), float(dims.get("z") or 0.0)) + planar_width = max(float(dims.get("x") or 0.0), float(dims.get("y") or 0.0)) + if planar_height >= height * 0.80 and planar_width >= width * 0.80: + continue + filtered_components.append(item) + components = filtered_components + + count_patterns = { + "shelves": (r"(?:共|合计)(\d+)块(?:层板|搁板)", r"(?:层板|搁板)(?:改为|调整为|数量为|实际(?:只有)?)(\d+)块"), + "drawers": ( + r"共(\d+)个(?:完整)?抽屉", + r"(?:只有|共|合计)?(\d+)个(?:左右|横向|并排|上下|竖向|垂直)?(?:并排|叠放|排列)?(?:的)?(?:完整)?抽屉", + ), + "doors": ( + r"共(\d+)(?:扇|块)(?:左右|上下)?(?:推拉门|移门|柜门|门板)", + r"(\d+)(?:扇|块)(?:左右|上下)?(?:推拉门|移门|柜门|门板)", + ), + } + parsed_counts: dict[str, int] = {} + chinese_counts = {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5} + paired_shelf_match = re.search( + r"(?:左右(?:两格|2格)?|两侧)(?:各|每侧)(\d+|[一二两三四五])块(?:层板|搁板)", + text, + ) + if paired_shelf_match: + per_side_text = paired_shelf_match.group(1) + per_side = int(per_side_text) if per_side_text.isdigit() else chinese_counts[per_side_text] + parsed_counts["shelves"] = max(0, min(50, per_side * 2)) + for key, patterns in count_patterns.items(): + if key in parsed_counts: + continue + for pattern in patterns: + match = re.search(pattern, text) + if match: + parsed_counts[key] = max(0, min(50, int(match.group(1)))) + break + # A later correction often mentions one physical shelf incidentally + # (for example "左右各1块层板") while preserving an already-confirmed + # total. Never reduce that contract from a bare singular phrase. On + # the first topology turn a simple "N块层板" remains accepted. + if "shelves" not in parsed_counts and "shelves" not in expected: + simple_shelf_match = re.search(r"(\d+)块(?:层板|搁板)", text) + if simple_shelf_match: + parsed_counts["shelves"] = max(0, min(50, int(simple_shelf_match.group(1)))) + shelf_count_value = parsed_counts.get("shelves") + if shelf_count_value is None and "shelves" in expected: + shelf_count_value = max(0, int(expected.get("shelves") or 0)) + if shelf_count_value is not None: + shelf_count = shelf_count_value + shelf_items = [item for item in components if cls._is_shelf_panel(item)] + components = [item for item in components if item not in shelf_items] + if shelf_count > 0: + template = dict(shelf_items[0]) if shelf_items else { + "part_id": "SHELF-USER-001", "name": "层板", "shape": "box", + "dimensions_mm": {"x": max(1.0, width - 2 * thickness), "y": max(1.0, depth - thickness), "z": thickness}, + "dimension_evidence": {}, "origins_mm": [], "material_group": "主体结构", + "recommended_species": "主体板材", "coating_area_m2": 0.0, + "edge_banding": {"exposed_length_m": 0.0, "confirmed": False, "includes_adhesive": True}, + "machining": {"holes": 0, "slots": 0, "slot_length_m": 0.0, "mortise_tenon": 0, "edge_profile_m": 0.0, "carving_area_m2": 0.0, "carving_path_m": 0.0, "evidence": "用户确认层板数量", "confidence": 0.55}, + "confidence": 0.55, + } + template["quantity"] = shelf_count + template["name"] = "层板" + template["origins_mm"] = [] + shelf_thickness = min(float(value) for value in (template.get("dimensions_mm") or {}).values()) + shelf_size_match = re.search( + r"(?:每块)?(?:层板|搁板)(?:尺寸|净尺寸)?(?:为|是|[::])?(\d+(?:\.\d+)?)\s*[x×*]\s*" + r"(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米)?", + text, + re.I, + ) + compartment_shelves = bool( + re.search(r"(?:左右(?:两格|2格)?|两侧)(?:各|每侧)(?:1|一)块(?:层板|搁板)", text) + or contract.get("shelf_layout") == "lower_left_right" + ) + if re.search(r"(?:左右(?:两格|2格)?|两侧)(?:各|每侧)(?:1|一)块(?:层板|搁板)", text): + contract["shelf_layout"] = "lower_left_right" + contract["shelf_zone"] = "below_drawers" + elif re.search(r"上部(?:共|有)?\d+块(?:层板|搁板)", text): + contract["shelf_layout"] = "upper_open" + contract["shelf_zone"] = "above_drawers" + shelf_width = ( + max(80.0, (width - 3 * thickness) / 2.0) + if compartment_shelves + else max(80.0, width - 2 * thickness) + ) + if shelf_size_match: + shelf_values = [float(shelf_size_match.group(index)) for index in (1, 2, 3)] + if str(shelf_size_match.group(4) or "").lower() in {"cm", "厘米"}: + shelf_values = [value * 10.0 for value in shelf_values] + template["dimensions_mm"] = dict(zip(("x", "y", "z"), shelf_values)) + template["dimension_evidence"] = { + axis: {"value": value, "source": "用户对话确认层板尺寸", "confidence": 1.0} + for axis, value in template["dimensions_mm"].items() + } + template["confidence"] = 1.0 + contract["shelf_dimensions_mm"] = dict(template["dimensions_mm"]) + elif isinstance(contract.get("shelf_dimensions_mm"), dict): + template["dimensions_mm"] = { + axis: float(contract["shelf_dimensions_mm"].get(axis) or template["dimensions_mm"].get(axis) or 1.0) + for axis in ("x", "y", "z") + } + template["dimension_evidence"] = { + axis: {"value": value, "source": "沿用本项目中用户已确认的层板尺寸", "confidence": 1.0} + for axis, value in template["dimensions_mm"].items() + } + template["confidence"] = 1.0 + elif any( + "用户对话确认层板尺寸" in str(evidence.get("source") or "") + or "沿用本项目中用户已确认的层板尺寸" in str(evidence.get("source") or "") + for evidence in (template.get("dimension_evidence") or {}).values() + if isinstance(evidence, dict) + ): + contract["shelf_dimensions_mm"] = dict(template.get("dimensions_mm") or {}) + else: + template["dimensions_mm"] = { + "x": shelf_width, + "y": max(80.0, depth - thickness), + "z": shelf_thickness, + } + components.append(template) + expected["shelves"] = shelf_count + + if "drawers" in parsed_counts: + updated["drawer_count"] = parsed_counts["drawers"] + expected["drawers"] = parsed_counts["drawers"] + if "doors" in parsed_counts and not no_doors: + expected["doors"] = parsed_counts["doors"] + + if re.search(r"(?:上下|竖向|垂直)(?:叠放|排列)?(?:的)?(?:两个|2个)抽屉|(?:两个|2个)(?:上下|竖向|垂直)(?:叠放|排列)?(?:的)?(?:完整)?抽屉|(?:两个|2个)抽屉(?:上下|竖向|垂直)", text): + contract["drawer_layout"] = "vertical" + elif re.search( + r"(?:左右|横向|并排)(?:的)?(?:两个|2个)(?:完整)?抽屉|" + r"(?:两个|2个)(?:左右|横向|并排)(?:并排|排列)?(?:的)?(?:完整)?抽屉|" + r"(?:两个|2个)抽屉(?:左右|横向|并排)", + text, + ): + contract["drawer_layout"] = "horizontal" + bottom_drawer_pattern = ( + r"底部(?:只有|仅|共|有)?(?:两个|2个)(?:上下|竖向|垂直)?" + r"(?:叠放|排列)?(?:的)?(?:完整)?抽屉" + ) + if re.search(bottom_drawer_pattern, text): + contract["drawer_zone"] = "bottom" + elif re.search( + r"(?:顶部|上部).{0,24}?(?:两个|2个)(?:左右|横向|并排)" + r"(?:并排|排列)?(?:的)?(?:完整)?抽屉", + text, + ): + contract["drawer_zone"] = "top" + + # In a vertical drawer stack every drawer front spans the same inner + # opening. Vision frequently mistakes the front height for its width; + # retain the unmeasured height but enforce the topology/overall-width + # constraint and keep it marked as an estimate. + if contract.get("drawer_layout") == "vertical": + inner_width = max(80.0, width - 2 * thickness) + for item in components: + if not cls._is_drawer_front(item): + continue + if "用户对话确认抽屉面板尺寸" in json.dumps(item.get("dimension_evidence") or {}, ensure_ascii=False): + item["origins_mm"] = [] + continue + dims = item.get("dimensions_mm") or {} + dims["x"] = inner_width + dims["y"] = thickness + item.setdefault("dimension_evidence", {})["x"] = { + "value": inner_width, + "source": "用户确认抽屉上下排列,按柜体内口宽度约束抽面宽度", + "confidence": 0.70, + } + item["origins_mm"] = [] + + if "踢脚板" in text or "踢脚" in text: + toe_height_match = re.search(r"踢脚(?:板)?(?:高度|高)?(?:为|是|[::])?(\d+(?:\.\d+)?)(mm|毫米|cm|厘米)?", text, re.I) + toe_height = float(toe_height_match.group(1)) if toe_height_match else 100.0 + if toe_height_match and str(toe_height_match.group(2) or "").lower() in {"cm", "厘米"}: + toe_height *= 10 + toe = next( + (item for item in components if any(token in cls._component_key(item) for token in ("踢脚", "toe kick", "toe-kick", "plinth"))), + None, + ) + if toe: + toe["name"] = "踢脚板" + toe["dimensions_mm"] = {"x": width, "y": thickness, "z": toe_height} + toe["origins_mm"] = [{"x": 0.0, "y": 0.0, "z": 0.0}] + toe["dimension_evidence"] = { + "x": {"value": width, "source": "用户确认踢脚板通宽", "confidence": 1.0}, + "y": {"value": thickness, "source": "按已确认板厚作为踢脚板厚度", "confidence": 0.9}, + "z": {"value": toe_height, "source": "用户确认踢脚板高度", "confidence": 1.0}, + } + else: + add_part("TOE-KICK-USER-001", "踢脚板", {"x": width, "y": thickness, "z": toe_height}) + required.append("踢脚板") + if re.search(r"下柜(?:中间)?(?:1块|一块)?(?:竖向)?(?:中间)?隔板|下柜中间(?:1块|一块)竖隔板|下柜中隔板", text): + candidate = next( + (item for item in components if "竖隔板" in cls._component_key(item) and "抽屉" not in cls._component_key(item)), + None, + ) + if candidate: + candidate["name"] = "下柜中间竖隔板" + else: + add_part("LOWER-DIVIDER-USER-001", "下柜中间竖隔板", {"x": thickness, "y": depth, "z": max(100.0, height * 0.55)}) + required.append("下柜中间竖隔板") + if re.search(r"抽屉下方(?:1块|一块)?(?:通宽)?(?:横向)?(?:隔板|横板)|通宽横隔板", text): + candidate = next( + ( + item for item in components + if any(token in cls._component_key(item) for token in ("通宽横隔板", "横隔板", "横向隔板")) + and "抽屉底板" not in cls._component_key(item) + ), + None, + ) + if candidate: + candidate["name"] = "抽屉下方通宽横隔板" + candidate["dimensions_mm"] = {"x": max(1.0, width - 2 * thickness), "y": depth, "z": thickness} + candidate["origins_mm"] = [] + else: + add_part("DRAWER-SUPPORT-USER-001", "抽屉下方通宽横隔板", {"x": max(1.0, width - 2 * thickness), "y": depth, "z": thickness}) + required.append("通宽横隔板") + if re.search(r"(?:两个|2个)抽屉之间(?:1块|一块)?(?:竖向)?(?:中隔板|隔板)", text): + candidate = next( + ( + item for item in components + if "中隔板" in cls._component_key(item) + and "下柜中间竖隔板" not in str(item.get("name") or "") + ), + None, + ) + if candidate: + candidate["name"] = "两个抽屉之间竖向中隔板" + drawer_front_height = max( + ( + float((item.get("dimensions_mm") or {}).get("z") or 0.0) + for item in components if cls._is_drawer_front(item) + ), + default=0.0, + ) + candidate["dimensions_mm"] = { + "x": thickness, "y": depth, + "z": drawer_front_height or max(100.0, height * 0.18), + } + candidate["origins_mm"] = [] + else: + drawer_front_height = max( + ( + float((item.get("dimensions_mm") or {}).get("z") or 0.0) + for item in components if cls._is_drawer_front(item) + ), + default=0.0, + ) + add_part( + "DRAWER-DIVIDER-USER-001", "两个抽屉之间竖向中隔板", + {"x": thickness, "y": depth, "z": drawer_front_height or max(100.0, height * 0.18)}, + ) + required.append("竖向中隔板") + + side_segment_match = re.search( + r"(?:左右)?侧板.{0,24}?(?:每侧)?(?:分为|分成|分|按)(\d+|[一二三四五六])段", + text, + ) + if side_segment_match: + chinese_numbers = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6} + raw_count = side_segment_match.group(1) + segment_count = int(raw_count) if raw_count.isdigit() else chinese_numbers.get(raw_count, 0) + heights_match = re.search( + r"(?:分段高度(?:按|为|分别为)?|高度分别为|高度按)[::]?" + r"(\d+(?:\.\d+)?)[、,/+](\d+(?:\.\d+)?)[、,/+]" + r"(\d+(?:\.\d+)?)(mm|毫米|cm|厘米)?", + text, + re.I, + ) + heights: list[float] = [] + if heights_match and segment_count == 3: + heights = [float(heights_match.group(index)) for index in (1, 2, 3)] + if str(heights_match.group(4) or "").lower() in {"cm", "厘米"}: + heights = [value * 10.0 for value in heights] + if not heights and segment_count > 1: + available_height = max(thickness, height - thickness) + side_profile = contract.get("side_profile") or {} + lower_height = float( + side_profile.get("lower_height_mm") + or contract.get("lower_section_height_mm") + or 0.0 + ) + if thickness < lower_height < available_height - thickness * (segment_count - 1): + upper_height = (available_height - lower_height) / (segment_count - 1) + heights = [lower_height] + [upper_height] * (segment_count - 1) + if segment_count > 1 and len(heights) == segment_count: + available_height = max(thickness, height - thickness) + if abs(sum(heights) - available_height) <= max(10.0, height * 0.03): + heights[-1] += available_height - sum(heights) + lower_depth_match = re.search(r"下段深(?:度)?(\d+(?:\.\d+)?)(mm|毫米|cm|厘米)?", text, re.I) + upper_depth_match = re.search(r"(?:中上段|上段)深(?:度)?(\d+(?:\.\d+)?)(mm|毫米|cm|厘米)?", text, re.I) + lower_depth = float(lower_depth_match.group(1)) if lower_depth_match else depth + upper_depth = float(upper_depth_match.group(1)) if upper_depth_match else float( + (contract.get("shelf_dimensions_mm") or {}).get("y") or depth + ) + if lower_depth_match and str(lower_depth_match.group(2) or "").lower() in {"cm", "厘米"}: + lower_depth *= 10.0 + if upper_depth_match and str(upper_depth_match.group(2) or "").lower() in {"cm", "厘米"}: + upper_depth *= 10.0 + side_items = [ + item for item in components + if any(token in cls._component_key(item) for token in ("侧板", "side panel")) + and "抽屉" not in cls._component_key(item) + ] + if side_items: + components = [item for item in components if item not in side_items] + labels = ("下段", "中段", "上段") if segment_count == 3 else tuple( + f"第{index + 1}段" for index in range(segment_count) + ) + for side_name, right_side in (("左", False), ("右", True)): + template = next( + (item for item in side_items if side_name in str(item.get("name") or "")), + side_items[0], + ) + z_cursor = 0.0 + for segment_index, segment_height in enumerate(heights): + copied = json.loads(json.dumps(template, ensure_ascii=False)) + copied["part_id"] = f"SIDE-{'R' if right_side else 'L'}-SEG-{segment_index + 1:02d}" + copied["name"] = f"{side_name}侧板({labels[segment_index]})" + copied["quantity"] = 1 + copied["shape"] = "box" + segment_depth = lower_depth if segment_index == 0 else upper_depth + copied["dimensions_mm"] = { + "x": thickness, + "y": segment_depth, + "z": segment_height, + } + copied["origins_mm"] = [{ + "x": max(0.0, width - thickness) if right_side else 0.0, + "y": max(0.0, depth - segment_depth), + "z": z_cursor, + }] + copied["origin_source"] = "用户确认的模块分段侧板装配坐标" + copied["origin_confidence"] = 1.0 + copied["module_segment_index"] = segment_index + copied["module_segment_count"] = segment_count + copied["dimension_evidence"] = { + axis: {"value": value, "source": "用户根据尺寸图确认侧板模块分段", "confidence": 1.0} + for axis, value in copied["dimensions_mm"].items() + } + copied["confidence"] = 1.0 + copied.pop("profile", None) + copied.setdefault("machining", {})["edge_profile_m"] = 0.0 + components.append(copied) + z_cursor += segment_height + contract["side_segments_per_side"] = segment_count + contract["side_segment_heights_mm"] = [round(value, 3) for value in heights] + contract["side_segment_lower_depth_mm"] = round(lower_depth, 3) + contract["side_segment_upper_depth_mm"] = round(upper_depth, 3) + contract["side_profile"] = { + "type": "segmented_modules", + "segments_per_side": segment_count, + "heights_mm": [round(value, 3) for value in heights], + "lower_depth_mm": round(lower_depth, 3), + "upper_depth_mm": round(upper_depth, 3), + } + + radius_match = re.search(r"R\s*(\d+(?:\.\d+)?)\s*(?:mm|毫米)?", str(message or ""), re.I) + if radius_match: + profile_radius = min(200.0, max(1.0, float(radius_match.group(1)))) + contract["edge_profile_radius_mm"] = profile_radius + contract["edge_profile_source"] = "用户对话/尺寸图明确标注" + for item in components: + key = cls._component_key(item) + if ( + any(token in key for token in ("侧板", "side panel", "正面轮廓", "front profile")) + and not any(token in key for token in ("抽屉", "drawer")) + ): + segment_count = max(1, int(item.get("module_segment_count") or 1)) + segment_index = int(item.get("module_segment_index") or 0) + if segment_count > 1 and segment_index not in {0, segment_count - 1}: + continue + item["profile"] = { + "type": "front_top_radius", + "radius_mm": profile_radius, + "source": "用户明确R角;在板件前上角生成真实CAD圆角", + "confidence": 1.0, + } + machining = item.setdefault("machining", {}) + machining["edge_profile_m"] = max( + float(machining.get("edge_profile_m") or 0.0), + math.pi * profile_radius / 2.0 / 1000.0, + ) + machining["evidence"] = f"用户/尺寸图明确R{profile_radius:g}圆角" + machining["confidence"] = 1.0 + + updated["components"] = components + declared_drawers = max(0, int(updated.get("drawer_count") or cls._drawer_front_count(components))) + updated["components"] = cls._limit_drawer_fronts(updated["components"], declared_drawers) + updated["components"] = cls._ensure_complete_drawer_boxes(updated["components"], overall, declared_drawers, drawer_layout=cls._drawer_layout(updated)) + if contract.get("drawer_layout") == "vertical": + inner_width = max(80.0, width - 2 * thickness) + drawer_depth = max(100.0, depth - max(30.0, thickness * 3.0)) + face_height_match = re.search( + r"(?:每个)?抽屉(?:面板|抽面)(?:尺寸)?(?:为|是|[::])?(\d+(?:\.\d+)?)\s*[x×*]\s*" + r"(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米)?", + text, + re.I, + ) + confirmed_face_height = 0.0 + confirmed_face_width = 0.0 + confirmed_face_thickness = 0.0 + if face_height_match: + face_values = [float(face_height_match.group(index)) for index in (1, 2, 3)] + if str(face_height_match.group(4) or "").lower() in {"cm", "厘米"}: + face_values = [value * 10.0 for value in face_values] + confirmed_face_width, confirmed_face_thickness, confirmed_face_height = face_values + else: + confirmed_front = next( + ( + item for item in updated["components"] + if cls._is_drawer_front(item) + and "用户对话确认抽屉面板尺寸" in json.dumps(item.get("dimension_evidence") or {}, ensure_ascii=False) + ), + None, + ) + if confirmed_front: + confirmed_dims = confirmed_front.get("dimensions_mm") or {} + confirmed_face_width = float(confirmed_dims.get("x") or 0.0) + confirmed_face_thickness = float(confirmed_dims.get("y") or 0.0) + confirmed_face_height = float(confirmed_dims.get("z") or 0.0) + box_width = max(60.0, (confirmed_face_width or inner_width) - 2 * thickness) + inferred_face_height = min(220.0, max(100.0, height * 0.14)) + for item in updated["components"]: + key = cls._component_key(item) + dims = item.get("dimensions_mm") or {} + if cls._is_drawer_front(item): + evidence_text = json.dumps(item.get("dimension_evidence") or {}, ensure_ascii=False) + user_confirmed_dimensions = "用户对话确认抽屉面板尺寸" in evidence_text + if confirmed_face_height or not user_confirmed_dimensions: + dims["x"] = confirmed_face_width or inner_width + dims["y"] = confirmed_face_thickness or thickness + dims["z"] = confirmed_face_height or min(float(dims.get("z") or inferred_face_height), inferred_face_height) + if confirmed_face_height: + item["dimension_evidence"] = { + axis: {"value": float(value), "source": "用户对话确认抽屉面板尺寸", "confidence": 1.0} + for axis, value in dims.items() + } + item["confidence"] = 1.0 + elif cls._drawer_box_role(item) in {"left", "right"}: + dims["x"], dims["y"], dims["z"] = thickness, drawer_depth, confirmed_face_height or inferred_face_height + elif cls._drawer_box_role(item) == "back": + dims["x"], dims["y"], dims["z"] = box_width, thickness, confirmed_face_height or inferred_face_height + elif cls._drawer_box_role(item) == "bottom": + dims["x"], dims["y"], dims["z"] = box_width, drawer_depth, thickness + item["origins_mm"] = [] + elif contract.get("drawer_layout") == "horizontal" and declared_drawers > 0: + inner_width = max(80.0, width - 2 * thickness) + front_width = max(60.0, inner_width / declared_drawers) + drawer_depth = max(100.0, depth - max(30.0, thickness * 3.0)) + fronts = [item for item in updated["components"] if cls._is_drawer_front(item)] + front_height = min( + 220.0, + max(100.0, statistics.median([ + float((item.get("dimensions_mm") or {}).get("z") or height * 0.18) + for item in fronts + ]) if fronts else height * 0.18), + ) + box_width = max(60.0, front_width - 2 * thickness) + for item in updated["components"]: + dims = item.get("dimensions_mm") or {} + if cls._is_drawer_front(item): + evidence_text = json.dumps(item.get("dimension_evidence") or {}, ensure_ascii=False) + if "用户对话确认抽屉面板尺寸" not in evidence_text: + dims.update({"x": front_width, "y": thickness, "z": front_height}) + elif cls._drawer_box_role(item) in {"left", "right"}: + dims.update({"x": thickness, "y": drawer_depth, "z": front_height}) + elif cls._drawer_box_role(item) == "back": + dims.update({"x": box_width, "y": thickness, "z": front_height}) + elif cls._drawer_box_role(item) == "bottom": + dims.update({"x": box_width, "y": drawer_depth, "z": thickness}) + if cls._drawer_box_role(item): + item["origins_mm"] = [] + updated["components"] = cls._merge_identical_drawer_box_roles(updated["components"]) + updated["door_count"] = cls._door_panel_count(updated["components"]) + updated["drawer_count"] = declared_drawers + contract["expected_counts"] = expected + contract["required_parts"] = list(dict.fromkeys(required)) + contract["source"] = "用户对话明确确认" + if parsed_counts or no_doors or required or any(token in text for token in ("推拉门", "移门", "滑门")): + contract["source_text"] = str(message or "")[:1000] + updated["user_topology_constraints"] = contract + updated["components"] = cls._canonicalize_simple_cabinet_origins( + updated["components"], overall, + has_doors=bool(updated.get("has_doors")), + door_mechanism=str(updated.get("door_mechanism") or ""), + ) + updated = cls._apply_topology_geometry_contract(updated) + updated["process_evidence"] = cls._process_evidence(updated) + return updated + + @classmethod + def _apply_topology_geometry_contract(cls, analysis: dict) -> dict: + """Turn persisted topology statements into stable product-coordinate geometry. + + A later conversational turn must not erase an earlier exact shelf size, + drawer zone or stepped side profile. This pass accepts explicit user + constraints or evidence-backed AI constraints and retains the source. + """ + contract = analysis.setdefault("user_topology_constraints", {}) + components = list(analysis.get("components") or []) + overall = analysis.get("overall_dimensions_mm") or {} + width = max(1.0, float(overall.get("x") or 1.0)) + depth = max(1.0, float(overall.get("y") or 1.0)) + height = max(1.0, float(overall.get("z") or 1.0)) + thicknesses = [ + min(float(value) for value in (item.get("dimensions_mm") or {}).values()) + for item in components + if len(item.get("dimensions_mm") or {}) == 3 and not cls._is_back_panel(item) + ] + thickness = statistics.median(thicknesses) if thicknesses else 18.0 + segmented_side_count = max(0, int(contract.get("side_segments_per_side") or 0)) + segment_heights = [float(value) for value in (contract.get("side_segment_heights_mm") or [])] + if contract.get("edge_profile_radius_mm") and contract.get("side_profile"): + # R30/Rxx describes the machined outline of the side panels. A + # vision retry sometimes invents separate "圆角装饰条" solids; + # keeping them duplicates the profile in CAD and BOM. + components = [ + item for item in components + if not any( + token in cls._component_key(item) + for token in ("圆角装饰条", "圆角条", "圆弧装饰条", "radius trim", "corner trim") + ) + ] + if segmented_side_count > 1 and len(segment_heights) == segmented_side_count: + lower_segment_depth = float(contract.get("side_segment_lower_depth_mm") or depth) + upper_segment_depth = float(contract.get("side_segment_upper_depth_mm") or depth) + # A persisted topology contract must materialise into manufacturable + # solids. Merely storing ``side_segments_per_side`` is insufficient: + # a later CAD review then still sees the two original continuous side + # panels and can incorrectly erase the multi-view conclusion. When + # the initial multi-modal pass (or an explicit user correction) has a + # complete, high-confidence segment contract, replace incomplete side + # geometry with one physical part per segment and side. + side_items = [ + item for item in components + if any(token in cls._component_key(item) for token in ("侧板", "side panel")) + and "抽屉" not in cls._component_key(item) + ] + segmented_items = [ + item for item in side_items + if item.get("module_segment_index") is not None + ] + if side_items and len(segmented_items) < segmented_side_count * 2: + components = [item for item in components if item not in side_items] + labels = ( + ("下段", "中段", "上段") + if segmented_side_count == 3 + else tuple(f"第{index + 1}段" for index in range(segmented_side_count)) + ) + contract_source = str(contract.get("source") or "结构拓扑约束") + contract_evidence = str( + contract.get("source_text") + or f"左右侧板每侧分{segmented_side_count}段" + )[:240] + segment_confidence = float( + (contract.get("ai_review_confidence") or {}).get("side_segments_per_side") + or (1.0 if "用户" in contract_source else 0.90) + ) + for side_name, right_side in (("左", False), ("右", True)): + template = next( + ( + item for item in side_items + if side_name in str(item.get("name") or "") + ), + side_items[0], + ) + for segment_index, segment_height in enumerate(segment_heights): + copied = json.loads(json.dumps(template, ensure_ascii=False)) + segment_depth = lower_segment_depth if segment_index == 0 else upper_segment_depth + copied["part_id"] = f"SIDE-{'R' if right_side else 'L'}-SEG-{segment_index + 1:02d}" + copied["name"] = f"{side_name}侧板({labels[segment_index]})" + copied["quantity"] = 1 + copied["shape"] = "box" + copied["dimensions_mm"] = { + "x": thickness, + "y": segment_depth, + "z": segment_height, + } + copied["origins_mm"] = [] + copied["origin_source"] = f"{contract_source}生成的模块分段侧板坐标" + copied["origin_confidence"] = segment_confidence + copied["module_segment_index"] = segment_index + copied["module_segment_count"] = segmented_side_count + copied["dimension_evidence"] = { + axis: { + "value": value, + "source": contract_evidence, + "confidence": segment_confidence, + } + for axis, value in copied["dimensions_mm"].items() + } + copied["confidence"] = segment_confidence + copied.pop("profile", None) + # The machining ledger of a formerly continuous side + # cannot be copied to every segment; doing so multiplies + # holes, slots and edge work. Per-segment manufacturing + # features are reconstructed by the CAD production pass. + copied["machining"] = { + "holes": 0, + "slots": 0, + "slot_length_m": 0.0, + "mortise_tenon": 0, + "edge_profile_m": 0.0, + "carving_area_m2": 0.0, + "carving_path_m": 0.0, + "evidence": "侧板分段后由CAD生产几何重新推导孔槽", + "confidence": segment_confidence, + } + components.append(copied) + z_offsets: list[float] = [] + segment_cursor = 0.0 + for segment_height in segment_heights: + z_offsets.append(segment_cursor) + segment_cursor += segment_height + shelf_dims = contract.get("shelf_dimensions_mm") or {} + upper_inner_width = min( + max(thickness, float(shelf_dims.get("x") or width - 2.0 * thickness)), + max(thickness, width - 2.0 * thickness), + ) + upper_outer_width = min(width, upper_inner_width + 2.0 * thickness) + upper_left_x = max(0.0, (width - upper_outer_width) / 2.0) + upper_right_x = min(width - thickness, upper_left_x + thickness + upper_inner_width) + for item in components: + raw_segment_index = item.get("module_segment_index") + if raw_segment_index is None: + continue + segment_index = int(raw_segment_index) + if not 0 <= segment_index < segmented_side_count: + continue + key = cls._component_key(item) + right_side = any(token in key for token in ("右侧板", "side-r", "right side")) + segment_depth = lower_segment_depth if segment_index == 0 else upper_segment_depth + segment_x = ( + (max(0.0, width - thickness) if right_side else 0.0) + if segment_index == 0 + else (upper_right_x if right_side else upper_left_x) + ) + item["dimensions_mm"] = { + "x": thickness, + "y": segment_depth, + "z": segment_heights[segment_index], + } + item["origins_mm"] = [{ + "x": segment_x, + "y": max(0.0, depth - segment_depth), + "z": z_offsets[segment_index], + }] + source = str(contract.get("source") or "结构拓扑约束") + item["origin_source"] = f"{source}生成的模块分段侧板装配坐标" + item["origin_confidence"] = float( + (contract.get("ai_review_confidence") or {}).get("side_segments_per_side") + or (1.0 if "用户" in source else 0.90) + ) + + toe_height = 0.0 + for item in components: + key = cls._component_key(item) + if any(token in key for token in ("踢脚", "toe kick", "toe-kick", "plinth")): + dims = item.setdefault("dimensions_mm", {}) + raw = sorted(max(1.0, float(dims.get(axis) or 1.0)) for axis in ("x", "y", "z")) + toe_height = max(toe_height, float(contract.get("toe_kick_height_mm") or raw[1])) + if toe_height <= thickness * 1.5: + toe_height = 100.0 + item["dimensions_mm"] = {"x": width, "y": thickness, "z": toe_height} + item["origins_mm"] = [{"x": 0.0, "y": 0.0, "z": 0.0}] + item["origin_source"] = "用户确认的竖向踢脚板装配布局" + item["origin_confidence"] = 1.0 + contract["toe_kick_height_mm"] = toe_height + + fronts = [item for item in components if cls._is_drawer_front(item)] + if contract.get("drawer_layout") == "vertical" and contract.get("drawer_zone") == "bottom" and fronts: + front_heights = [ + float((item.get("dimensions_mm") or {}).get("z") or 0.0) + for item in fronts for _ in range(max(1, int(item.get("quantity") or 1))) + ] + lower_height = float(contract.get("lower_section_height_mm") or 0.0) + if lower_height <= 0: + open_access = max(80.0, height * 0.06) + lower_height = min(height * 0.45, sum(front_heights) + open_access + 4.0 * thickness) + contract["lower_section_height_mm"] = round(lower_height, 3) + shelf_dims = contract.get("shelf_dimensions_mm") or {} + upper_depth = float(shelf_dims.get("y") or 0.0) + radius = float(contract.get("edge_profile_radius_mm") or 0.0) + if 0 < upper_depth < depth - max(5.0, thickness * 0.5): + for item in components: + key = cls._component_key(item) + if any(token in key for token in ("侧板", "side panel")) and "抽屉" not in key: + if segmented_side_count > 1: + continue + item["profile"] = { + "type": "stepped_front_radius", + "lower_depth_mm": depth, + "upper_depth_mm": upper_depth, + "lower_height_mm": lower_height, + "radius_mm": radius, + "source": "底部抽屉深柜与上部浅层板的用户确认尺寸形成阶梯侧板", + "confidence": 1.0, + } + item["origins_mm"] = [] + elif any(token in key for token in ("顶板", "桌面", "台面", "top panel", "table top", "tabletop", "desktop")): + top_dims = item.setdefault("dimensions_mm", {}) + top_dims["x"] = min(width, max(float(shelf_dims.get("x") or width), thickness)) + top_dims["y"] = upper_depth + top_dims["z"] = thickness + item["origins_mm"] = [] + if segmented_side_count <= 1: + contract["side_profile"] = { + "type": "stepped_front_radius", + "lower_depth_mm": depth, + "upper_depth_mm": upper_depth, + "lower_height_mm": lower_height, + "radius_mm": radius, + } + for item in components: + if cls._is_shelf_panel(item): + item["origins_mm"] = [] + + if contract.get("shelf_layout") == "lower_left_right": + for item in components: + if not cls._is_shelf_panel(item): + continue + dims = item.setdefault("dimensions_mm", {}) + dims["x"] = max(80.0, (width - 3.0 * thickness) / 2.0) + dims["y"] = min(depth, max(80.0, float(dims.get("y") or depth - thickness))) + dims["z"] = thickness + item["origins_mm"] = [] + + # The lower divider terminates at the drawer support. Keeping an old + # visual origin here creates a floating divider hidden behind the door. + front_height = max( + (float((item.get("dimensions_mm") or {}).get("z") or 0.0) for item in fronts), + default=max(100.0, height * 0.18), + ) + support_z = max(toe_height + thickness, height - thickness - front_height - thickness) + for item in components: + key = cls._component_key(item) + if "下柜中间竖隔板" in key: + start_z = toe_height + thickness + item["dimensions_mm"] = { + "x": thickness, "y": depth, "z": max(thickness, support_z - start_z) + } + item["origins_mm"] = [] + elif any(token in key for token in ("抽屉下方", "抽屉支撑", "drawer support")): + item["origins_mm"] = [] + elif any(token in key for token in ("抽屉之间", "抽屉中隔", "drawer divider")): + # This divider belongs to the drawer-front zone. A visual + # re-read may legitimately refine the front height; carrying + # the old divider height forward then pushes it beyond the + # product envelope and creates a correction loop. + item["dimensions_mm"] = { + "x": thickness, + "y": depth, + "z": front_height, + } + item["origins_mm"] = [] + analysis["components"] = components + return analysis + + @classmethod + def _is_drawer_composite(cls, item: dict) -> bool: + """Return true for a whole drawer/box placeholder, never a front panel.""" + name = str(item.get("name") or "").lower() + return any( + token in name + for token in ( + "抽屉面板箱体", + "抽屉箱体", + "抽屉总成", + "drawer box", + "drawer body", + "drawer assembly", + ) + ) + + @classmethod + def _is_drawer_front(cls, item: dict) -> bool: + if cls._is_drawer_composite(item): + return False + name = str(item.get("name") or "").lower() + # Exclude structural drawer parts before matching the broad + # ``抽屉面板`` token. Names such as ``抽屉面板后板`` previously + # inflated two physical drawers into five fronts and duplicated slide, + # handle and installation costs. + if any( + token in name + for token in ( + "左帮", "右帮", "侧板", "后帮", "后板", "抽背", "底板", "抽底", + "下方", "之间", "隔板", "横板", "支撑板", "托板", + "箱体", "总成", "box", "body", "assembly", + "drawer left", "drawer right", "drawer side", "drawer back", "drawer bottom", + ) + ): + return False + if any(token in name for token in ("抽屉面板", "抽面", "drawer front")): + return True + # Vision models sometimes return a drawer as one composite volume named + # simply "抽屉(左侧)". Treat that composite as the drawer-front + # anchor; _ensure_complete_drawer_boxes will turn it into a real panel + # before adding the side/back/bottom parts. + return ( + ("抽屉" in name or "drawer" in name) + and not any(token in name for token in ("左帮", "右帮", "侧板", "后帮", "后板", "底板")) + ) + + @classmethod + def _normalize_drawer_composites(cls, components: list[dict], declared_drawers: int = 0) -> list[dict]: + """Remove duplicate whole-drawer placeholders or turn them into front anchors.""" + real_fronts = [item for item in components if cls._is_drawer_front(item)] + composites = [item for item in components if cls._is_drawer_composite(item)] + if not composites: + return components + if real_fronts: + return [item for item in components if not cls._is_drawer_composite(item)] + + converted: list[dict] = [] + remaining = declared_drawers + for item in components: + if not cls._is_drawer_composite(item): + converted.append(item) + continue + current = dict(item) + quantity = max(1, int(current.get("quantity") or 1)) + if declared_drawers > 0 and remaining <= 0: + continue + if declared_drawers > 0: + quantity = min(quantity, remaining) + remaining -= quantity + current["name"] = "抽屉面板(由抽屉箱体识别结果拆分)" + current["quantity"] = quantity + current["material_group"] = "抽屉面板" + converted.append(current) + return converted + + @classmethod + def _drawer_front_count(cls, components: list[dict]) -> int: + return sum(max(1, int(item.get("quantity") or 1)) for item in components if cls._is_drawer_front(item)) + + @staticmethod + def _drawer_layout(analysis: dict) -> str: + """Keep source-observed drawer topology separate from quantity. + + Explicit user corrections win; an AI layout needs image evidence, not + merely a self-assigned label. Unknown is never an instruction to divide + a cabinet's width by its drawer count. + """ + contract = analysis.get("user_topology_constraints") or {} + layout = str(contract.get("drawer_layout") or "") + if layout in {"horizontal", "vertical", "mixed"} and ( + str(contract.get("source") or "").startswith("用户") + or float((contract.get("ai_review_confidence") or {}).get("drawer_layout") or 0.0) >= 0.85 + ): + return layout + layout = str(analysis.get("drawer_layout") or "") + if ( + layout in {"horizontal", "vertical", "mixed"} + and float(analysis.get("drawer_layout_confidence") or 0.0) >= 0.80 + and str(analysis.get("drawer_layout_evidence") or "").strip() + ): + return layout + return "unknown" + + @classmethod + def _normalize_drawer_support_roles(cls, components: list[dict]) -> list[dict]: + """Do not count a source-identified fixed divider as a moving bottom. + + Geometry alone cannot prove a part's purpose. Require an explicit + source structure role plus evidence, and retain the original label in + the audit instead of renaming every wide panel by a size heuristic. + """ + result = [] + for item in components: + dims = item.get("dimensions_mm") or {} + if ( + item.get("component_role") == "drawer_support" + and item.get("assembly_group") == "carcass" + and str(item.get("structure_evidence") or "").strip() + and len(dims) == 3 + and float(dims.get("z") or 0) <= 40 + and float(dims.get("x") or 0) > float(dims.get("z") or 0) * 3 + and float(dims.get("y") or 0) > float(dims.get("z") or 0) * 3 + ): + current = dict(item) + current.setdefault("source_component_name", str(item.get("name") or "")) + current["name"] = "柜体承托横隔板(位于抽屉下方)" + current["material_group"] = "主体结构" + result.append(current) + else: + result.append(item) + return result + + @classmethod + def _drawer_box_role(cls, item: dict) -> str: + if item.get("component_role") == "drawer_support" and item.get("assembly_group") == "carcass": + return "" + key = cls._component_key(item) + if cls._is_drawer_front(item): + return "front" + if any(token in key for token in ("抽屉左帮", "抽屉左侧板", "左抽板", "drawer left")): + return "left" + if any(token in key for token in ("抽屉右帮", "抽屉右侧板", "右抽板", "drawer right")): + return "right" + if any(token in key for token in ("抽屉后帮", "抽屉后板", "抽背", "drawer back")): + return "back" + if any(token in key for token in ("抽屉底板", "抽底", "drawer bottom")): + return "bottom" + return "" + + @classmethod + def _normalize_drawer_box_roles(cls, components: list[dict], drawer_count: int) -> list[dict]: + """Canonicalize drawer-part names and cap stale duplicate role sets. + + Vision commonly returns ``抽屉左侧板/右侧板/后板`` while the + deterministic completer historically looked only for ``帮板``. That + created a second full drawer box. Explicit drawer count is the hard + upper bound for every physical drawer role. + """ + remaining = { + role: max(0, int(drawer_count)) + for role in ("left", "right", "back", "bottom") + } + role_names = { + "left": "抽屉左帮板(左侧板)", + "right": "抽屉右帮板(右侧板)", + "back": "抽屉后帮板(后侧板)", + "bottom": "抽屉底板", + } + result: list[dict] = [] + for item in components: + role = cls._drawer_box_role(item) + if role not in remaining or drawer_count <= 0: + result.append(item) + continue + if remaining[role] <= 0: + continue + current = dict(item) + quantity = min(max(1, int(current.get("quantity") or 1)), remaining[role]) + current["quantity"] = quantity + if not re.search(r"第\s*\d+\s*个?抽屉", str(current.get("name") or "")): + current["name"] = role_names[role] + current["material_group"] = "抽屉底板" if role == "bottom" else "抽屉结构" + remaining[role] -= quantity + result.append(current) + return result + + @classmethod + def _merge_identical_drawer_box_roles(cls, components: list[dict]) -> list[dict]: + """Merge identical drawer-box rows while preserving physical quantity. + + Vision often emits one row per drawer for the same left/right/back/bottom + part. Keeping those rows separate made both anonymous ``item_index=0`` + instances occupy the first drawer. Merging makes the quantity explicit + in BOM and lets assembly placement address drawer 1 and drawer 2. + Non-identical dimensions are intentionally kept separate. + """ + role_names = { + "left": "抽屉左帮板(左侧板)", + "right": "抽屉右帮板(右侧板)", + "back": "抽屉后帮板(后侧板)", + "bottom": "抽屉底板", + } + merged: list[dict] = [] + by_signature: dict[tuple, dict] = {} + for item in components: + role = cls._drawer_box_role(item) + if role not in {"left", "right", "back", "bottom"}: + merged.append(item) + continue + dims = item.get("dimensions_mm") or {} + signature = ( + role, + round(float(dims.get("x") or 0.0), 3), + round(float(dims.get("y") or 0.0), 3), + round(float(dims.get("z") or 0.0), 3), + str(item.get("material_group") or ""), + str(item.get("recommended_species") or ""), + ) + existing = by_signature.get(signature) + if existing is None: + current = dict(item) + current["name"] = role_names[role] + current["quantity"] = max(1, int(current.get("quantity") or 1)) + current["origins_mm"] = [] + by_signature[signature] = current + merged.append(current) + else: + existing["quantity"] = int(existing.get("quantity") or 1) + max(1, int(item.get("quantity") or 1)) + existing["origins_mm"] = [] + return merged + + @classmethod + def _limit_drawer_fronts(cls, components: list[dict], declared_drawers: int) -> list[dict]: + """Keep at most the explicitly declared number of physical fronts.""" + if declared_drawers <= 0: + return components + remaining = declared_drawers + result: list[dict] = [] + for item in components: + if not cls._is_drawer_front(item): + result.append(item) + continue + if remaining <= 0: + continue + current = dict(item) + quantity = min(max(1, int(current.get("quantity") or 1)), remaining) + current["quantity"] = quantity + remaining -= quantity + result.append(current) + return result + + @classmethod + def _is_door_panel(cls, item: dict) -> bool: + key = cls._component_key(item) + explicit_door_name = any(token in key for token in ("柜门", "门板", "推拉门", "移门", "door panel", "sliding door")) + if not explicit_door_name and any( + token in key for token in ("隔板", "中隔", "竖隔", "横隔", "层板", "搁板", "shelf", "divider", "partition") + ): + return False + evidence_text = json.dumps(item.get("dimension_evidence") or {}, ensure_ascii=False).lower() + return ( + explicit_door_name + or "门片" in evidence_text + or "door leaf" in evidence_text + ) + + @classmethod + def _door_panel_count(cls, components: list[dict]) -> int: + return sum(max(1, int(item.get("quantity") or 1)) for item in components if cls._is_door_panel(item)) + + @classmethod + def _normalize_door_dimensions(cls, raw: object) -> dict: + value = raw if isinstance(raw, dict) else {} + widths = [ + float(item) for item in (value.get("leaf_widths") or [])[:12] + if cls._float(item, 0.0) and float(item) > 0 + ] + return { + "leaf_widths": widths, + "leaf_height": max(0.0, cls._float(value.get("leaf_height"), 0.0) or 0.0), + "source": str(value.get("source") or "")[:160], + "confidence": min(1.0, max(0.0, cls._float(value.get("confidence"), 0.0) or 0.0)), + } + + @classmethod + def _apply_confirmed_door_dimensions(cls, components: list[dict], evidence: dict) -> list[dict]: + height = float(evidence.get("leaf_height") or 0.0) + if height <= 0 or float(evidence.get("confidence") or 0.0) < 0.65: + return components + widths = [float(value) for value in (evidence.get("leaf_widths") or []) if float(value) > 0] + door_index = 0 + for item in components: + if not cls._is_door_panel(item): + continue + dims = item.get("dimensions_mm") or {} + if len(dims) != 3: + continue + ordered = sorted((float(value) for value in dims.values()), reverse=True) + thickness = ordered[-1] + width = widths[min(door_index, len(widths) - 1)] if widths else ordered[1] + item["dimensions_mm"] = {"x": width, "y": thickness, "z": height} + item["dimension_evidence"] = { + "source": str(evidence.get("source") or "尺寸图门片标注"), + "confidence": float(evidence.get("confidence") or 0.0), + } + door_index += max(1, int(item.get("quantity") or 1)) + return components + + @classmethod + def _infer_door_mechanism(cls, analysis: dict, components: list[dict], hardware: list[dict]) -> str: + raw = str(analysis.get("door_mechanism") or "").strip().lower() + aliases = { + "sliding": "sliding", "slide": "sliding", "推拉": "sliding", "滑门": "sliding", "移门": "sliding", + "hinged": "hinged", "hinge": "hinged", "平开": "hinged", "铰链": "hinged", + "none": "none", "无门": "none", "unknown": "unknown", "未知": "unknown", + } + for token, normalized in aliases.items(): + if token in raw: + return normalized + evidence = " ".join( + [str(analysis.get("product_name") or ""), str(analysis.get("product_type") or "")] + + [str(x) for x in (analysis.get("assumptions") or [])] + + [str(item.get("name") or "") + " " + str(item.get("evidence") or "") for item in hardware] + + [str(item.get("name") or "") for item in components] + ).lower() + if any(token in evidence for token in ("推拉门", "滑门", "移门", "柜门通过滑轨", "sliding door")): + return "sliding" + if any(token in evidence for token in ("平开门", "柜门铰链", "door hinge", "hinged door")): + return "hinged" + if not bool(analysis.get("has_doors")) and not cls._door_panel_count(components): + return "none" + return "unknown" + + @classmethod + def _remove_duplicate_door_facades(cls, components: list[dict]) -> list[dict]: + door_faces: list[dict] = [] + for item in components: + if not cls._is_door_panel(item): + continue + dims = sorted((float(value) for value in (item.get("dimensions_mm") or {}).values()), reverse=True) + if len(dims) == 3: + name = str(item.get("name") or "").lower() + side = "left" if any(token in name for token in ("左", "left")) else ( + "right" if any(token in name for token in ("右", "right")) else "" + ) + door_faces.append({"planar": (dims[0], dims[1]), "side": side}) + if not door_faces: + return components + combined_facades: list[tuple[float, float]] = [] + if len(door_faces) >= 2: + for width_axis in (0, 1): + combined_facades.append( + ( + sum(float(face["planar"][width_axis]) for face in door_faces), + max(float(face["planar"][1 - width_axis]) for face in door_faces), + ) + ) + kept: list[dict] = [] + for item in components: + key = cls._component_key(item) + if "前挡板" in key and not cls._is_door_panel(item): + dims = sorted((float(value) for value in (item.get("dimensions_mm") or {}).values()), reverse=True) + name = str(item.get("name") or "").lower() + side = "left" if any(token in name for token in ("左", "left")) else ( + "right" if any(token in name for token in ("右", "right")) else "" + ) + if len(dims) == 3: + combined_duplicate = bool( + not side + and any( + abs(dims[0] - total_width) <= max(60.0, total_width * 0.08) + and abs(dims[1] - leaf_height) <= max(30.0, leaf_height * 0.08) + for total_width, leaf_height in combined_facades + ) + ) + if combined_duplicate: + continue + for face in door_faces: + face_dims = face["planar"] + same_side = bool(side and face["side"] and side == face["side"]) + same_planar_edge = any( + abs(value - target) <= max(12.0, target * 0.04) + for value in dims[:2] + for target in face_dims + ) + # Vision sometimes emits one sliding leaf twice: once as + # a generic left/right "front baffle" with cabinet depth + # as its other dimension, and once as an explicit sliding + # door with the whole cabinet height. Side identity plus + # one matching face edge is sufficient evidence to drop + # the generic duplicate; its second dimension is known to + # be unreliable in this failure mode. + if same_side and same_planar_edge: + break + if all( + abs(dims[index] - face_dims[index]) + <= max(30.0, face_dims[index] * 0.08) + for index in (0, 1) + ): + break + else: + kept.append(item) + continue + kept.append(item) + return kept + + @classmethod + def _ensure_complete_drawer_boxes( + cls, components: list[dict], overall: dict, declared_drawers: int = 0, + *, drawer_layout: str = "unknown", + ) -> list[dict]: + rough_drawer_count = declared_drawers or cls._drawer_front_count(components) + components = cls._normalize_drawer_support_roles(components) + components = cls._normalize_drawer_box_roles(components, rough_drawer_count) + thickness_candidates = [ + min(float(value) for value in (item.get("dimensions_mm") or {}).values()) + for item in components + if len(item.get("dimensions_mm") or {}) == 3 + and not cls._is_drawer_front(item) + and min(float(value) for value in (item.get("dimensions_mm") or {}).values()) <= 40.0 + ] + inferred_panel_thickness = min(thickness_candidates) if thickness_candidates else 18.0 + normalized_components: list[dict] = [] + for item in components: + current = dict(item) + name = str(current.get("name") or "") + dims = sorted((float(value) for value in (current.get("dimensions_mm") or {}).values()), reverse=True) + if cls._is_drawer_front(current) and len(dims) == 3: + # Inputs already use product axes. Do not sort them back by + # length, replace front thickness with the thinnest back panel, + # cap tall drawer fronts, or divide widths by drawer count. + # Those operations destroy valid vertical and unequal layouts. + raw = current["dimensions_mm"] + front_width = float(raw["x"]) + front_height = float(raw["z"]) + front_thickness = float(raw["y"]) + if front_thickness > 40.0: + # A legacy composite box has depth on Y, not face thickness. + front_thickness = inferred_panel_thickness + if not any(token in name for token in ("抽屉面板", "抽面")): + current["name"] = name.replace("抽屉", "抽屉面板", 1) + current["dimensions_mm"] = { + "x": front_width, + "y": front_thickness, + "z": front_height, + } + current["material_group"] = "抽屉面板" + normalized_components.append(current) + components = normalized_components + fronts = [item for item in components if cls._is_drawer_front(item)] + drawer_count = declared_drawers or cls._drawer_front_count(components) + if drawer_count <= 0 or not fronts: + return components + front_dims = sorted((float(value) for value in (fronts[0].get("dimensions_mm") or {}).values()), reverse=True) + front_height = front_dims[1] if len(front_dims) == 3 else min(220.0, max(100.0, float(overall.get("z") or 800.0) * 0.18)) + drawer_depth = max(100.0, float(overall.get("y") or 400.0) - max(30.0, inferred_panel_thickness * 3.0)) + split_components: list[dict] = [] + used_ids = {str(item.get("part_id") or "") for item in components} + for item in components: + name = str(item.get("name") or "") + key = name.lower() + generic_side = any(token in key for token in ("抽屉侧板", "drawer side")) and not any( + token in key for token in ("左帮", "右帮", "drawer left", "drawer right") + ) + if not generic_side: + split_components.append(item) + continue + for sequence in range(1, drawer_count + 1): + for side_code, side_name in (("L", "左帮板(左侧板)"), ("R", "右帮板(右侧板)")): + part_id = f"DRAWER-{side_code}-{sequence:03d}" + while part_id in used_ids: + part_id += "X" + used_ids.add(part_id) + split_components.append( + { + **item, + "part_id": part_id, + "name": f"第{sequence}个抽屉{side_name}", + "quantity": 1, + "dimensions_mm": {"x": inferred_panel_thickness, "y": drawer_depth, "z": front_height}, + "material_group": "抽屉结构", + "edge_banding": { + "exposed_length_m": drawer_depth / 1000, + "confirmed": True, + "includes_adhesive": True, + }, + } + ) + components = split_components + # Check actual part names only. A model labelling a composite drawer's + # material_group as "抽屉底板" must not make the real bottom disappear. + clarified_components: list[dict] = [] + for item in components: + current = dict(item) + name = str(current.get("name") or "") + lowered = name.lower() + if any(token in lowered for token in ("抽屉左帮", "抽屉左侧板", "左抽板", "drawer left")) and "左侧板" not in name: + current["name"] = f"{name}(左侧板)" + elif any(token in lowered for token in ("抽屉右帮", "抽屉右侧板", "右抽板", "drawer right")) and "右侧板" not in name: + current["name"] = f"{name}(右侧板)" + elif any(token in lowered for token in ("抽屉后帮", "抽屉后板", "抽背", "drawer back")) and "后侧板" not in name: + current["name"] = f"{name}(后侧板)" + clarified_components.append(current) + components = clarified_components + required_tokens = { + "left": ("抽屉左帮", "抽屉左侧板", "左抽板", "drawer left"), + "right": ("抽屉右帮", "抽屉右侧板", "右抽板", "drawer right"), + "back": ("抽屉后帮", "抽屉后板", "抽背", "drawer back"), + "bottom": ("抽屉底板", "抽底", "drawer bottom"), + } + role_counts = { + role: sum( + max(1, int(item.get("quantity") or 1)) + for item in components + if any(token in str(item.get("name") or "").lower() for token in tokens) + ) + for role, tokens in required_tokens.items() + } + deficits = {role: drawer_count - count for role, count in role_counts.items() if count < drawer_count} + if not deficits: + return components + used_ids = {str(item.get("part_id") or "") for item in components} + result = list(components) + overall_depth = max(120.0, float(overall.get("y") or 400.0)) + front_instances: list[dict] = [] + for front in fronts: + front_instances.extend([front] * max(1, int(front.get("quantity") or 1))) + if not front_instances: + return result + for role, deficit in deficits.items(): + first_sequence = role_counts[role] + 1 + for sequence in range(first_sequence, first_sequence + deficit): + front = front_instances[min(sequence - 1, len(front_instances) - 1)] + raw_dims = front.get("dimensions_mm") or {} + if len(raw_dims) != 3: + continue + front_width, front_height, thickness = (float(raw_dims[axis]) for axis in ("x", "z", "y")) + drawer_depth = max(100.0, overall_depth - max(30.0, thickness * 3.0)) + inner_width = max(80.0, front_width - thickness * 2.0) + specs = { + "left": (f"DRAWER-L-{sequence:03d}", f"第{sequence}个抽屉左帮板(左侧板)", {"x": thickness, "y": drawer_depth, "z": front_height}, drawer_depth / 1000), + "right": (f"DRAWER-R-{sequence:03d}", f"第{sequence}个抽屉右帮板(右侧板)", {"x": thickness, "y": drawer_depth, "z": front_height}, drawer_depth / 1000), + "back": (f"DRAWER-BACK-{sequence:03d}", f"第{sequence}个抽屉后帮板(后侧板)", {"x": inner_width, "y": thickness, "z": front_height}, 0.0), + "bottom": (f"DRAWER-BOTTOM-{sequence:03d}", f"第{sequence}个抽屉底板", {"x": inner_width, "y": drawer_depth, "z": thickness}, 0.0), + } + part_id, name, dims, exposed_edge = specs[role] + while part_id in used_ids: + part_id += "X" + used_ids.add(part_id) + result.append( + { + "part_id": part_id, + "name": name, + "quantity": 1, + "shape": "box", + "dimensions_mm": dims, + "origins_mm": [], + "material_group": "抽屉底板" if role == "bottom" else "抽屉结构", + "recommended_species": str(front.get("recommended_species") or "主体板材"), + "coating_area_m2": 0.0, + "edge_banding": {"exposed_length_m": exposed_edge, "confirmed": True, "includes_adhesive": True}, + "dimension_evidence": { + axis: { + "value": float(value), + "source": "根据柜深和抽面尺寸反推,待原图与CAD装配复核", + "confidence": 0.45, + } + for axis, value in dims.items() + }, + "machining": {"holes": 0, "slots": 0, "mortise_tenon": 0, "edge_profile_m": 0.0, "carving_area_m2": 0.0, "carving_path_m": 0.0, "evidence": "抽屉箱体完整性校验自动补齐", "confidence": 0.45}, + "confidence": 0.45, + } + ) + return result + + @classmethod + def _is_back_panel(cls, item: dict) -> bool: + key = cls._component_key(item) + return any(word in key for word in ("背板", "back panel", "backboard", "rear panel")) + + @classmethod + def _cabinet_carcass_missing(cls, analysis: dict) -> list[str]: + """Find physically required cabinet shell parts missing from the BOM.""" + components = analysis.get("components") or [] + product_key = f"{analysis.get('product_name') or ''} {analysis.get('product_type') or ''}".lower() + keys = [cls._component_key(item) for item in components] + side_count = sum( + max(1, int(item.get("quantity") or 1)) + for item in components + if any(token in cls._component_key(item) for token in ("侧板", "side panel", "left side", "right side")) + and not any(token in cls._component_key(item) for token in ("抽屉", "drawer")) + ) + casework_by_geometry = side_count >= 2 and ( + int(analysis.get("drawer_count") or 0) > 0 + or any(cls._is_shelf_panel(item) for item in components) + ) + is_cabinet = bool(analysis.get("has_doors")) or casework_by_geometry or any( + token in product_key + for token in ("柜", "书架", "cabinet", "bookcase", "shelving", "sideboard", "cupboard", "wardrobe", "dresser") + ) + if not is_cabinet: + return [] + has_top = any( + any(token in key for token in ("顶板", "桌面", "台面", "top panel", "table top", "tabletop", "desktop", "cabinet top")) + for key in keys + ) + has_bottom = any( + any(token in key for token in ("柜体底板", "柜底板", "bottom panel", "cabinet bottom", "底板")) + and not any(token in key for token in ("抽屉", "drawer", "背板", "back")) + for key in keys + ) + missing: list[str] = [] + if not has_top: + missing.append("顶板") + if side_count < 2: + missing.extend(["左侧板", "右侧板"][: 2 - side_count]) + if not has_bottom: + missing.append("柜体底板") + return missing + + @classmethod + def _add_inferred_cabinet_carcass(cls, analysis: dict) -> dict: + """Add only the minimum load-bearing shell after explicit user approval.""" + updated = json.loads(json.dumps(analysis, ensure_ascii=False)) + missing = cls._cabinet_carcass_missing(updated) + if not missing: + return updated + components = updated.setdefault("components", []) + overall = updated.get("overall_dimensions_mm") or {} + width = max(100.0, float(overall.get("x") or 1.0)) + depth = max(80.0, float(overall.get("y") or 1.0)) + height = max(100.0, float(overall.get("z") or 1.0)) + thicknesses = [ + min(float(value) for value in (item.get("dimensions_mm") or {}).values()) + for item in components + if len(item.get("dimensions_mm") or {}) == 3 + and not cls._is_back_panel(item) + and min(float(value) for value in (item.get("dimensions_mm") or {}).values()) <= 40.0 + ] + thickness = statistics.median(thicknesses) if thicknesses else 18.0 + thickness = min(30.0, max(4.0, float(thickness))) + used_ids = {str(item.get("part_id") or "") for item in components} + + def add(part_id: str, name: str, dims: dict[str, float], origin: dict[str, float]) -> None: + candidate = part_id + while candidate in used_ids: + candidate += "X" + used_ids.add(candidate) + evidence = { + axis: { + "value": float(value), + "source": "用户同意按基础柜体CAD装配关系补齐,等待CAD复核", + "confidence": 0.55, + } + for axis, value in dims.items() + } + components.append( + { + "part_id": candidate, + "name": name, + "quantity": 1, + "shape": "box", + "dimensions_mm": dims, + "dimension_evidence": evidence, + "origins_mm": [origin], + "material_group": "主体结构", + "recommended_species": "主体板材", + "coating_area_m2": 0.0, + "edge_banding": {"exposed_length_m": 0.0, "confirmed": False, "includes_adhesive": True}, + "machining": { + "holes": 0, "slots": 0, "mortise_tenon": 0, "edge_profile_m": 0.0, + "carving_area_m2": 0.0, "carving_path_m": 0.0, + "evidence": "基础柜体完整性规则补齐;孔槽仍需图片或用户证据", "confidence": 0.55, + }, + "confidence": 0.55, + } + ) + + if "顶板" in missing: + add("TOP-AUTO-001", "顶板", {"x": width, "y": depth, "z": thickness}, {"x": 0.0, "y": 0.0, "z": height - thickness}) + if "左侧板" in missing: + add("SIDE-L-AUTO-001", "左侧板", {"x": thickness, "y": depth, "z": height - thickness}, {"x": 0.0, "y": 0.0, "z": 0.0}) + if "右侧板" in missing: + add("SIDE-R-AUTO-001", "右侧板", {"x": thickness, "y": depth, "z": height - thickness}, {"x": width - thickness, "y": 0.0, "z": 0.0}) + if "柜体底板" in missing: + add( + "BOTTOM-AUTO-001", + "柜体底板", + {"x": max(1.0, width - 2 * thickness), "y": depth, "z": thickness}, + {"x": thickness, "y": 0.0, "z": 0.0}, + ) + updated.setdefault("structure_decisions", {})["cabinet_carcass"] = { + "completed": True, + "source": "user_approved_cad_inference", + "added_parts": missing, + } + assumption = "用户已同意按基础柜体装配关系补齐缺失的承重外壳部件;CAD生成后仍需与原图联合复核" + if assumption not in (updated.get("assumptions") or []): + updated.setdefault("assumptions", []).append(assumption) + return updated + + @classmethod + def _is_aux_component(cls, item: dict) -> bool: + key = cls._component_key(item) + return cls._is_back_panel(item) or any( + word in key for word in ("抽屉底板", "抽屉底", "抽底", "drawer bottom", "内衬", "lining") + ) + + @staticmethod + def _component_dimension_confidence(item: dict) -> tuple[float, list[str]]: + evidence = item.get("dimension_evidence") or {} + if not isinstance(evidence, dict): + return 0.0, ["未提供逐件尺寸证据"] + if "confidence" in evidence or "source" in evidence: + source = str(evidence.get("source") or "未提供逐件尺寸证据") + return float(evidence.get("confidence") or 0.0), [source] + confidences: list[float] = [] + sources: list[str] = [] + for axis in ("x", "y", "z"): + axis_evidence = evidence.get(axis) if isinstance(evidence.get(axis), dict) else {} + confidences.append(float(axis_evidence.get("confidence") or 0.0)) + sources.append(str(axis_evidence.get("source") or f"{axis}轴未提供证据")) + return min(confidences or [0.0]), sources + + def _low_confidence_dimension_items(self, analysis: dict) -> list[dict]: + """Return cost-sensitive dimensions that were inferred rather than measured.""" + items: list[dict] = [] + axis_labels = {"x": "整体宽度", "y": "整体深度", "z": "整体高度"} + for axis in ("x", "y", "z"): + evidence = ((analysis.get("dimension_evidence") or {}).get(axis) or {}) + confidence = float(evidence.get("confidence") or 0.0) + source = str(evidence.get("source") or "未标注") + if confidence < 0.75 or any(token in source for token in ("未标注", "估算", "推测", "反推")): + items.append( + { + "kind": "overall", + "id": f"overall_{axis}", + "label": axis_labels[axis], + "value": float((analysis.get("overall_dimensions_mm") or {}).get(axis) or 0.0), + "reason": source, + "confidence": confidence, + } + ) + for component in analysis.get("components") or []: + confidence, sources = self._component_dimension_confidence(component) + if confidence >= 0.75 and not any( + token in " ".join(sources) for token in ("未标注", "估算", "推测", "反推", "未提供") + ): + continue + dims = component.get("dimensions_mm") or {} + items.append( + { + "kind": "component", + "id": str(component.get("part_id") or ""), + "label": str(component.get("name") or "未命名部件"), + "value": "{:.0f}×{:.0f}×{:.0f}mm".format( + float(dims.get("x") or 0.0), + float(dims.get("y") or 0.0), + float(dims.get("z") or 0.0), + ), + "reason": ";".join(dict.fromkeys(sources))[:240], + "confidence": confidence, + } + ) + # Keep the interaction readable while preserving the full count. + return items[:30] + + def _critical_structure_questions(self, analysis: dict) -> list[dict]: + components = analysis.get("components") or [] + product_key = f"{analysis.get('product_name') or ''} {analysis.get('product_type') or ''}".lower() + is_cabinet = bool(analysis.get("has_doors")) or any( + word in product_key for word in ("柜", "cabinet", "sideboard", "cupboard", "wardrobe", "dresser") + ) + questions: list[dict] = [] + depth_evidence = (analysis.get("dimension_evidence") or {}).get("y") or {} + if depth_evidence and ( + float(depth_evidence.get("value") or 0) <= 1 + or float(depth_evidence.get("confidence") or 0) < 0.60 + or "未标注" in str(depth_evidence.get("source") or "") + ): + questions.append( + { + "id": "overall_depth", + "label": "整体深度", + "reason": "尺寸图没有可靠的整体深度,局部门宽不能当成柜深", + "prompt": "请直接输入柜深,例如“柜深40cm”", + } + ) + decisions = analysis.get("structure_decisions") or {} + missing_carcass = self._cabinet_carcass_missing(analysis) + if missing_carcass and not bool((decisions.get("cabinet_carcass") or {}).get("completed")): + questions.append( + { + "id": "cabinet_carcass", + "label": "基础柜体结构", + "reason": "当前BOM缺少成立所必需的" + "、".join(missing_carcass), + "prompt": "可同意按整体尺寸和现有板厚补齐后由CAD与原图复核,或直接补充这些部件尺寸", + "missing_parts": missing_carcass, + } + ) + ai_confirms_no_back = bool( + analysis.get("has_back_panel") is False + and float(analysis.get("back_panel_confidence") or 0.0) >= 0.85 + and str(analysis.get("back_panel_evidence") or "").strip() + ) + if ( + is_cabinet + and not any(self._is_back_panel(item) for item in components) + and "back_panel" not in decisions + and not ai_confirms_no_back + ): + questions.append( + { + "id": "back_panel", + "label": "背板", + "reason": "图片中未识别到背板,不能默认省略后直接报价", + "prompt": "请确认有无背板;如有,请选择或输入厚度", + } + ) + if self._door_panel_count(components) > 0 and str(analysis.get("door_mechanism") or "unknown") == "unknown": + questions.append( + { + "id": "door_mechanism", + "label": "柜门开启方式", + "reason": "无法确认是推拉门还是平开门,两者不能共用铰链成本", + "prompt": "请选择推拉门或平开门", + } + ) + door_evidence = analysis.get("door_dimensions_mm") or {} + door_panels = [item for item in components if self._is_door_panel(item)] + overall_height = float((analysis.get("overall_dimensions_mm") or {}).get("z") or 0.0) + impossible_full_height_door = bool( + self._drawer_front_count(components) + and overall_height > 0 + and any( + max(float(value) for value in (item.get("dimensions_mm") or {}).values()) + >= overall_height * 0.90 + for item in door_panels + if item.get("dimensions_mm") + ) + ) + user_confirmed_door_height = str(door_evidence.get("source") or "").startswith("用户") + if impossible_full_height_door and not user_confirmed_door_height: + questions.append( + { + "id": "door_panel_height", + "label": "移门高度", + "reason": "识别值接近柜体总高,与顶部抽屉结构冲突", + "prompt": "请直接输入门片高度,例如“推拉门高54cm”", + } + ) + low_confidence_items = self._low_confidence_dimension_items(analysis) + consent = analysis.get("estimation_consent") or {} + if low_confidence_items and not bool(consent.get("approved")): + questions.append( + { + "id": "cad_estimation_consent", + "label": "低置信尺寸", + "reason": f"还有{len(low_confidence_items)}项成本敏感尺寸没有直接标注,当前素材不能默认当成精确数值", + "prompt": "请补传尺寸图、在对话中填写关键尺寸,或明确同意按CAD合理值预估", + "items": low_confidence_items, + } + ) + return questions + + def _request_dimension_supplement(self, project_id: str) -> None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT asset_id,validation_json FROM project_assets + WHERE project_id=? AND role='dimension' AND replaced_by_asset_id IS NULL + ORDER BY asset_id DESC LIMIT 1 + """, + (project_id,), + ).fetchone() + if not row: + return + try: + validation = json.loads(row[1] or "{}") + except json.JSONDecodeError: + validation = {} + validation["issues"] = ["缺少关键结构尺寸,等待单张替换"] + validation["replacement_requested"] = True + conn.execute( + "UPDATE project_assets SET validation_status='rejected',validation_json=? WHERE asset_id=?", + (json.dumps(validation, ensure_ascii=False), int(row[0])), + ) + + def _apply_user_component_dimensions(self, analysis: dict, message: str) -> bool: + text = str(message or "") + changed = False + + drawer_front_group_match = re.search( + r"(?:左右)?(?:两个|2个)?抽屉面板(?:尺寸)?(?:均为|均是|为|是|[::])\s*" + r"(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)\s*" + r"(mm|毫米|cm|厘米|公分)?", + text, + re.I, + ) + if drawer_front_group_match: + values = [float(drawer_front_group_match.group(index)) for index in (1, 2, 3)] + if str(drawer_front_group_match.group(4) or "").lower() in {"cm", "厘米", "公分"}: + values = [value * 10.0 for value in values] + face_width, face_thickness, face_height = values + for component in analysis.get("components") or []: + role = self._drawer_box_role(component) + if role == "front": + component["dimensions_mm"] = { + "x": face_width, "y": face_thickness, "z": face_height, + } + component["dimension_evidence"] = { + axis: {"value": value, "source": "用户对话确认左右抽屉面板尺寸", "confidence": 1.0} + for axis, value in component["dimensions_mm"].items() + } + component["confidence"] = 1.0 + changed = True + elif role in {"left", "right", "back"}: + component.setdefault("dimensions_mm", {})["z"] = face_height + component["origins_mm"] = [] + + drawer_match = re.search( + r"抽屉(?:箱体)?(?:内)?(?:尺寸)?\s*[::]?\s*(\d+(?:\.\d+)?)\s*[x×*]\s*" + r"(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|公分)?", + text, + re.I, + ) + if drawer_match: + inner_width, drawer_depth, side_height = (float(drawer_match.group(i)) for i in (1, 2, 3)) + if str(drawer_match.group(4) or "").lower() in {"cm", "厘米", "公分"}: + inner_width, drawer_depth, side_height = ( + inner_width * 10.0, + drawer_depth * 10.0, + side_height * 10.0, + ) + for component in analysis.get("components") or []: + key = self._component_key(component) + dims = component.get("dimensions_mm") or {} + thickness = min((float(value) for value in dims.values()), default=12.0) + if any(token in key for token in ("抽屉左帮", "抽屉右帮", "drawer left", "drawer right")): + component["dimensions_mm"] = {"x": thickness, "y": drawer_depth, "z": side_height} + elif any(token in key for token in ("抽屉后帮", "抽背", "drawer back")): + component["dimensions_mm"] = {"x": inner_width, "y": thickness, "z": side_height} + elif any(token in key for token in ("抽屉底板", "抽底", "drawer bottom")): + component["dimensions_mm"] = {"x": inner_width, "y": drawer_depth, "z": thickness} + else: + continue + component["dimension_evidence"] = { + axis: {"value": float(value), "source": "用户对话确认抽屉箱体尺寸", "confidence": 1.0} + for axis, value in component["dimensions_mm"].items() + } + component["confidence"] = 1.0 + changed = True + + for component in analysis.get("components") or []: + name = re.sub(r"[((].*?[))]", "", str(component.get("name") or "")).strip() + if len(name) < 2 or name not in text: + continue + start = text.find(name) + len(name) + segment = text[start:start + 80] + match = re.search( + r"(?:尺寸)?\s*[::]?\s*(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)\s*[x×*]\s*" + r"(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|公分)?", + segment, + re.I, + ) + if not match: + continue + values = [float(match.group(i)) for i in (1, 2, 3)] + if str(match.group(4) or "").lower() in {"cm", "厘米", "公分"}: + values = [value * 10.0 for value in values] + component["dimensions_mm"] = dict(zip(("x", "y", "z"), values)) + component["dimension_evidence"] = { + axis: {"value": value, "source": f"用户对话确认{name}尺寸", "confidence": 1.0} + for axis, value in component["dimensions_mm"].items() + } + component["confidence"] = 1.0 + changed = True + return changed + + @classmethod + def _restore_confirmed_topology_after_reanalysis(cls, previous: dict, refreshed: dict) -> dict: + """Restore explicit prior confirmations after a CAD correction re-read. + + A vision retry may legitimately improve one dimension, but it is not + allowed to erase parts/counts that the user already confirmed earlier + in the same project. + """ + result = json.loads(json.dumps(refreshed, ensure_ascii=False)) + contract = dict(previous.get("user_topology_constraints") or {}) + # Dimensions and their evidence must move together. Preserve actual + # user confirmations / independent arrow readings, but allow new source + # evidence to improve a prior guess. Merely having corrected axes once + # is not grounds to freeze an unverified value forever. + for axis in ("x", "y", "z"): + old_evidence = (previous.get("dimension_evidence") or {}).get(axis) or {} + new_evidence = (result.get("dimension_evidence") or {}).get(axis) or {} + explicit = (previous.get("user_dimension_contract") or {}).get("overall_dimensions_mm") or {} + keep = axis in explicit or ( + old_evidence.get("kind") == "source_arrow_label" + and new_evidence.get("kind") != "source_arrow_label" + ) or ( + bool(previous.get("dimension_axis_corrections")) + and float(old_evidence.get("confidence") or 0.0) >= 0.8 + and float(new_evidence.get("confidence") or 0.0) < 0.8 + ) + if keep: + result.setdefault("overall_dimensions_mm", {})[axis] = (previous.get("overall_dimensions_mm") or {}).get(axis) + result.setdefault("dimension_evidence", {})[axis] = json.loads(json.dumps(old_evidence, ensure_ascii=False)) + for key in ("user_dimension_contract", "dimension_axis_corrections", "source_dimension_audit"): + if previous.get(key): + result[key] = json.loads(json.dumps(previous[key], ensure_ascii=False)) + if not contract: + return result + result["user_topology_constraints"] = json.loads(json.dumps(contract, ensure_ascii=False)) + for key in ( + "door_mechanism", "door_dimensions_mm", + "structure_decisions", "estimation_consent", + ): + if previous.get(key) not in (None, {}, []): + result[key] = json.loads(json.dumps(previous[key], ensure_ascii=False)) + previous_components = list(previous.get("components") or []) + current = list(result.get("components") or []) + + segment_count = max(0, int(contract.get("side_segments_per_side") or 0)) + previous_segment_sides = [ + item for item in previous_components + if item.get("module_segment_index") is not None + and any(token in cls._component_key(item) for token in ("侧板", "side panel")) + and "抽屉" not in cls._component_key(item) + ] + if segment_count > 1 and len(previous_segment_sides) == segment_count * 2: + current = [ + item for item in current + if not ( + any(token in cls._component_key(item) for token in ("侧板", "side panel")) + and "抽屉" not in cls._component_key(item) + ) + ] + current.extend(json.loads(json.dumps(previous_segment_sides, ensure_ascii=False))) + + def restore_to_count(predicate, target: int) -> None: + nonlocal current + actual = sum(max(1, int(item.get("quantity") or 1)) for item in current if predicate(item)) + deficit = max(0, int(target) - actual) + if deficit <= 0: + return + for old in previous_components: + if deficit <= 0 or not predicate(old): + continue + copied = json.loads(json.dumps(old, ensure_ascii=False)) + quantity = min(max(1, int(copied.get("quantity") or 1)), deficit) + copied["quantity"] = quantity + copied["origins_mm"] = [] + current.append(copied) + deficit -= quantity + + expected = dict(contract.get("expected_counts") or {}) + restore_to_count(cls._is_door_panel, int(expected.get("doors") or 0)) + restore_to_count(cls._is_drawer_front, int(expected.get("drawers") or 0)) + restore_to_count(cls._is_shelf_panel, int(expected.get("shelves") or 0)) + for required_name in contract.get("required_parts") or []: + if not any(str(required_name) in str(item.get("name") or "") for item in current): + old = next( + (item for item in previous_components if str(required_name) in str(item.get("name") or "")), + None, + ) + if old: + copied = json.loads(json.dumps(old, ensure_ascii=False)) + copied["origins_mm"] = [] + current.append(copied) + carcass = (previous.get("structure_decisions") or {}).get("cabinet_carcass") or {} + if carcass.get("completed"): + restore_to_count( + lambda item: any(token in cls._component_key(item) for token in ("顶板", "top panel")), 1 + ) + restore_to_count( + lambda item: any(token in cls._component_key(item) for token in ("柜体底板", "柜底板", "bottom panel", "底板")) + and "抽屉" not in cls._component_key(item), + 1, + ) + restore_to_count( + lambda item: any(token in cls._component_key(item) for token in ("侧板", "side panel")) + and "抽屉" not in cls._component_key(item), + 2, + ) + if ((previous.get("structure_decisions") or {}).get("back_panel") or {}).get("present") is False: + current = [item for item in current if not cls._is_back_panel(item)] + result["components"] = current + result["drawer_count"] = int(expected.get("drawers") or previous.get("drawer_count") or cls._drawer_front_count(current)) + result["door_count"] = int(expected.get("doors") or previous.get("door_count") or cls._door_panel_count(current)) + result["has_doors"] = bool(result["door_count"]) + result["components"] = cls._ensure_complete_drawer_boxes( + result["components"], result.get("overall_dimensions_mm") or {}, result["drawer_count"], drawer_layout=cls._drawer_layout(result) + ) + result["components"] = cls._merge_identical_drawer_box_roles(result["components"]) + return cls._apply_topology_geometry_contract(result) + + @classmethod + def _apply_high_confidence_cad_review_patch(cls, analysis: dict) -> tuple[dict, dict]: + """Convert repeated visual findings into a narrow topology correction. + + A CAD reviewer already saw the original views, dimension drawing and + generated model together. When several views agree at high confidence, + use that evidence to repair only the contradicted topology. Re-running + an unconstrained full BOM extraction here can erase correct parts and + was the root cause of CAD correction loops. + """ + review = dict(analysis.get("cad_review") or {}) + # Rendered CAD is the hypothesis being tested, never another source + # vote. Otherwise two matching (wrong) CAD renders outvote one photo. + findings = [ + item for item in (review.get("view_findings") or []) + if isinstance(item, dict) and (cls._float(item.get("asset_id"), 0) or 0) > 0 + and str(item.get("declared_role") or "") not in {"cad", "assembled", "exploded"} + ] + # Repeating the same photo is not independent corroboration. + findings = list({int(item["asset_id"]): item for item in findings}.values()) + if not findings: + return analysis, {} + + def consensus_count(key: str) -> tuple[int | None, float, list[str]]: + votes: dict[int, list[tuple[float, str, bool]]] = {} + for finding in findings: + confidence = min(1.0, max(0.0, cls._float(finding.get("count_confidence"), 0.0) or 0.0)) + counts = finding.get("visible_counts") or {} + if confidence < 0.80 or key not in counts: + continue + try: + value = max(0, min(50, int(counts[key]))) + except (TypeError, ValueError): + continue + votes.setdefault(value, []).append( + (confidence, str(finding.get("evidence") or "")[:160], bool(finding.get("complete_product_front_visible"))) + ) + if not votes: + return None, 0.0, [] + value, records = max( + votes.items(), + key=lambda item: (len(item[1]), sum(record[0] for record in item[1])), + ) + strong_front = any(is_front and confidence >= 0.90 for confidence, _evidence, is_front in records) + if len(records) < 2 and not strong_front: + return None, 0.0, [] + return value, max(record[0] for record in records), [record[1] for record in records if record[1]] + + def consensus_label(key: str, allowed: set[str]) -> tuple[str, float]: + votes: dict[str, list[float]] = {} + for finding in findings: + confidence = min(1.0, max(0.0, cls._float(finding.get("count_confidence"), 0.0) or 0.0)) + value = str(finding.get(key) or "unknown").strip().lower() + if confidence >= 0.80 and value in allowed: + votes.setdefault(value, []).append(confidence) + if not votes: + return "", 0.0 + value, records = max(votes.items(), key=lambda item: (len(item[1]), sum(item[1]))) + if len(records) < 2 and max(records) < 0.90: + return "", 0.0 + return value, max(records) + + count_values: dict[str, int] = {} + confidence_values: dict[str, float] = {} + evidence: list[str] = [] + for key in ("doors", "drawers", "shelves"): + value, confidence, notes = consensus_count(key) + if value is not None: + count_values[key] = value + confidence_values[key] = confidence + evidence.extend(notes) + + drawer_layout, drawer_layout_confidence = consensus_label( + "drawer_layout", {"horizontal", "vertical"} + ) + shelf_layout, shelf_layout_confidence = consensus_label( + "shelf_layout", {"upper_open", "lower_left_right", "general"} + ) + + segment_candidates: list[tuple[float, int, list[float], str]] = [] + radius_candidates: list[tuple[float, float, str]] = [] + for finding in findings: + segment_confidence = min( + 1.0, + max(0.0, cls._float(finding.get("side_panel_segmentation_confidence"), 0.0) or 0.0), + ) + segment_count = max(0, int(cls._float(finding.get("side_panel_segments_per_side"), 0) or 0)) + heights = [ + float(value) for value in (finding.get("side_panel_segment_heights_mm") or []) + if cls._float(value, 0.0) + ][:8] + if segment_confidence >= 0.85 and segment_count > 1: + segment_candidates.append( + (segment_confidence, segment_count, heights, str(finding.get("evidence") or "")[:160]) + ) + radius_confidence = min( + 1.0, + max(0.0, cls._float(finding.get("edge_profile_confidence"), 0.0) or 0.0), + ) + radius = max(0.0, cls._float(finding.get("edge_profile_radius_mm"), 0.0) or 0.0) + if radius_confidence >= 0.85 and radius > 0: + radius_candidates.append( + (radius_confidence, radius, str(finding.get("evidence") or "")[:160]) + ) + + correction_parts: list[str] = [] + if count_values.get("doors") == 0: + correction_parts.append("无柜门") + elif "doors" in count_values: + correction_parts.append(f"共{count_values['doors']}扇柜门") + if "drawers" in count_values: + layout_text = "上下排列" if drawer_layout == "vertical" else "左右并排" if drawer_layout == "horizontal" else "" + correction_parts.append(f"共{count_values['drawers']}个{layout_text}完整抽屉") + if drawer_layout: + confidence_values["drawer_layout"] = drawer_layout_confidence + if "shelves" in count_values: + zone_text = "上部" if shelf_layout == "upper_open" else "下柜左右两格" if shelf_layout == "lower_left_right" else "" + if shelf_layout == "lower_left_right" and count_values["shelves"] % 2 == 0: + correction_parts.append(f"下柜左右两格每侧{count_values['shelves'] // 2}块层板") + else: + correction_parts.append(f"{zone_text}共{count_values['shelves']}块层板") + + segment_record = max(segment_candidates, default=None) + if segment_record: + segment_confidence, segment_count, heights, segment_evidence = segment_record + segment_text = f"左右侧板每侧分{segment_count}段" + if len(heights) == segment_count: + segment_text += ",分段高度为" + "、".join(f"{value:g}" for value in heights) + "mm" + components = list(analysis.get("components") or []) + overall_depth = float((analysis.get("overall_dimensions_mm") or {}).get("y") or 0.0) + shelf_depths = [ + float((item.get("dimensions_mm") or {}).get("y") or 0.0) + for item in components if cls._is_shelf_panel(item) + and float((item.get("dimensions_mm") or {}).get("y") or 0.0) > 0 + ] + upper_depth = statistics.median(shelf_depths) if shelf_depths else overall_depth + if overall_depth > 0 and upper_depth > 0 and upper_depth < overall_depth * 0.95: + segment_text += f",下段深{overall_depth:g}mm,中上段深{upper_depth:g}mm" + correction_parts.append(segment_text) + confidence_values["side_segments_per_side"] = segment_confidence + if segment_evidence: + evidence.append(segment_evidence) + + radius_record = max(radius_candidates, default=None) + if radius_record: + radius_confidence, radius, radius_evidence = radius_record + correction_parts.append(f"外轮廓R{radius:g}mm") + confidence_values["edge_profile_radius_mm"] = radius_confidence + if radius_evidence: + evidence.append(radius_evidence) + + if not correction_parts: + return analysis, {} + correction_text = ",".join(correction_parts) + "。" + patched = cls._apply_user_structure_overrides(analysis, correction_text) + + back_votes: list[tuple[bool, float]] = [] + for finding in findings: + if not isinstance(finding.get("has_back_panel"), bool): + continue + confidence = min(1.0, max(0.0, cls._float(finding.get("count_confidence"), 0.0) or 0.0)) + if confidence >= 0.85: + back_votes.append((bool(finding["has_back_panel"]), confidence)) + if back_votes and len({value for value, _confidence in back_votes}) == 1: + has_back = back_votes[0][0] + confidence_values["has_back_panel"] = max(confidence for _value, confidence in back_votes) + if not has_back: + patched["components"] = [item for item in (patched.get("components") or []) if not cls._is_back_panel(item)] + patched.setdefault("structure_decisions", {})["back_panel"] = { + "present": False, + "source": "AI多视角与CAD联合复核", + "confidence": confidence_values["has_back_panel"], + } + + contract = patched.setdefault("user_topology_constraints", {}) + contract["source"] = "AI多视角与CAD联合复核" + contract["source_text"] = correction_text + contract["ai_review_evidence"] = list(dict.fromkeys(note for note in evidence if note))[:8] + contract["ai_review_confidence"] = confidence_values + patched["cad_review_patch"] = { + "applied_at": cls._now(), + "correction_text": correction_text, + "confidence": confidence_values, + "source_reviewed_at": review.get("reviewed_at"), + } + patched.pop("production_joinery", None) + patched = cls._apply_topology_geometry_contract(patched) + old_parts = {str(item.get("part_id") or ""): item for item in analysis.get("components") or []} + ai_confidence = min(confidence_values.values(), default=0.85) + for part in patched.get("components") or []: + old = old_parts.get(str(part.get("part_id") or ""), {}) + for field, confidence_key in (("origin_source", "origin_confidence"),): + if "用户" in str(part.get(field) or "") and part.get(field) != old.get(field): + part[field] = "AI多视角证据约束生成的装配坐标(非用户确认)" + part[confidence_key] = ai_confidence + for axis, item in (part.get("dimension_evidence") or {}).items(): + old_item = (old.get("dimension_evidence") or {}).get(axis) or {} + if "用户" in str(item.get("source") or "") and item != old_item: + item["source"] = "AI多视角证据与装配约束推导(非用户实测)" + item["confidence"] = min(float(item.get("confidence") or 0), ai_confidence) + return patched, patched["cad_review_patch"] + + def _apply_user_connection_decisions(self, analysis: dict, message: str) -> bool: + """Apply only explicit, production-relevant connection confirmations. + + These decisions are intentionally separate from vision inference. They + repair the CAD geometry, component dimensions and schedule ledger in one + transaction so a corrected back panel cannot retain its old position or + be overwritten by stale OCR dimensions on the next build. + """ + text = str(message or "") + changed = False + decisions = analysis.setdefault("structure_decisions", {}) + + if ( + "背板" in text + and "嵌槽" in text + and any(token in text.lower() for token in ("6mm", "6 mm", "6毫米", "四边", "按槽深")) + ): + components = list(analysis.get("components") or []) + back_panels = [item for item in components if self._is_back_panel(item)] + side_panels = [ + item for item in components + if any(token in self._component_key(item) for token in ("侧板", "side panel", "left side", "right side")) + and "抽屉" not in self._component_key(item) + ] + if back_panels and side_panels: + side_thickness = statistics.median( + self._layout_dimensions_for_component(analysis, item)["x"] for item in side_panels + ) + side_height = statistics.median( + self._layout_dimensions_for_component(analysis, item)["z"] for item in side_panels + ) + panel_thicknesses = [ + min(self._layout_dimensions_for_component(analysis, item).values()) + for item in components + if not self._is_back_panel(item) and not self._is_aux_component(item) + ] + panel_thickness = statistics.median(panel_thicknesses) if panel_thicknesses else side_thickness + groove_depth = min(6.0, max(2.5, panel_thickness * 0.35)) + overall = analysis.get("overall_dimensions_mm") or {} + opening_width = max(1.0, float(overall.get("x") or 1.0) - 2 * side_thickness) + target_width = opening_width + 2 * groove_depth + target_height = side_height + 2 * groove_depth + side_origins_z = [ + float(origin.get("z") or 0.0) + for item in side_panels + for origin in (item.get("origins_mm") or []) + if isinstance(origin, dict) + ] + origin_z = (min(side_origins_z) if side_origins_z else panel_thickness) - groove_depth + for back in back_panels: + dims = dict(back.get("dimensions_mm") or {}) + back_thickness = float(dims.get("y") or min(dims.values()) or 9.0) + back["dimensions_mm"] = {"x": target_width, "y": back_thickness, "z": target_height} + back["dimension_evidence"] = { + "x": {"value": target_width, "source": "用户确认四边嵌槽后按净开口+双边槽深修正", "confidence": 1.0}, + "y": {"value": back_thickness, "source": "用户确认保留背板厚度", "confidence": 1.0}, + "z": {"value": target_height, "source": "用户确认四边嵌槽后按侧板高度+双边槽深修正", "confidence": 1.0}, + } + back["origins_mm"] = [ + { + "x": side_thickness - groove_depth, + "y": max( + 0.0, + float(overall.get("y") or 0.0) + - 8.0 + - (back_thickness + 0.5) / 2 + - back_thickness / 2, + ), + "z": max(0.0, origin_z), + } + ] + back["confidence"] = 1.0 + for row in (analysis.get("dimension_schedule") or {}).get("rows") or []: + if self._schedule_role(row) != "back": + continue + row["dimensions_mm"] = {"x": target_width, "y": back_panels[0]["dimensions_mm"]["y"], "z": target_height} + row["source"] = "用户确认四边嵌槽后的生产尺寸" + row["confidence"] = 1.0 + decisions["back_panel_connection"] = { + "connection_code": "four_side_groove", + "groove_depth_mm": groove_depth, + "source": "user_confirmed", + } + changed = True + + if ( + any(token in text for token in ("柜腿", "支腿", "桌腿", "腿部")) + and "M8" in text.upper() + and any(token in text for token in ("预埋", "吊脚螺栓", "螺栓")) + ): + decisions["leg_connection"] = { + "connection_code": "m8_hanger_bolt", + "source": "用户确认采用M8吊脚螺栓+柜底预埋螺母", + } + changed = True + return changed + + @staticmethod + def _back_panel_thickness(message: str, default: float = 9.0) -> float: + text = str(message or "") + patterns = ( + r"背板[^\d]{0,8}(\d+(?:\.\d+)?)\s*(?:mm|毫米)", + r"(\d+(?:\.\d+)?)\s*(?:mm|毫米)[^\n,,]{0,8}背板", + ) + for pattern in patterns: + match = re.search(pattern, text, re.I) + if match: + return min(30.0, max(3.0, float(match.group(1)))) + return default + + def _add_confirmed_back_panel(self, analysis: dict, thickness_mm: float) -> dict: + updated = json.loads(json.dumps(analysis, ensure_ascii=False)) + if any(self._is_back_panel(item) for item in updated.get("components") or []): + updated["structure_questions"] = [ + item for item in updated.get("structure_questions") or [] if item.get("id") != "back_panel" + ] + return updated + overall = updated.get("overall_dimensions_mm") or {} + components = updated.get("components") or [] + side_thicknesses = [ + min(float(value) for value in item["dimensions_mm"].values()) + for item in components + if any(word in self._component_key(item) for word in ("侧板", "side panel")) + ] + horizontal_thicknesses = [ + min(float(value) for value in item["dimensions_mm"].values()) + for item in components + if any(word in self._component_key(item) for word in ("顶板", "底板", "top panel", "bottom panel")) + ] + side_t = min(side_thicknesses) if side_thicknesses else 20.0 + horizontal_t = min(horizontal_thicknesses) if horizontal_thicknesses else 20.0 + width = max(1.0, float(overall.get("x") or 1.0) - 2 * side_t) + side_items = [ + item for item in components + if any(word in self._component_key(item) for word in ("侧板", "side panel")) + and not any(word in self._component_key(item) for word in ("抽屉", "drawer")) + ] + side_heights = [ + float(self._layout_dimensions_for_component(updated, item)["z"]) + for item in side_items + ] + height = min(side_heights) if side_heights else max(1.0, float(overall.get("z") or 1.0) - 2 * horizontal_t) + depth = max(1.0, float(overall.get("y") or 1.0)) + side_origins_z = [ + float(origin.get("z") or 0.0) + for item in side_items + for origin in (item.get("origins_mm") or []) + if isinstance(origin, dict) + ] + if side_origins_z: + back_origin_z = min(side_origins_z) + else: + leg_height = max( + ( + float(self._layout_dimensions_for_component(updated, item)["z"]) + for item in components + if self._is_leg_component(item) + ), + default=0.0, + ) + back_origin_z = leg_height + horizontal_t + updated.setdefault("components", []).append( + { + "part_id": "BACK-001", + "name": "背板", + "quantity": 1, + "shape": "box", + "dimensions_mm": {"x": width, "y": thickness_mm, "z": height}, + "dimension_evidence": { + "x": {"value": width, "source": "根据左右侧板内净宽反推", "confidence": 0.85}, + "y": {"value": thickness_mm, "source": "用户对话确认背板厚度", "confidence": 1.0}, + "z": {"value": height, "source": "按已识别柜体侧板高度匹配", "confidence": 0.90}, + }, + "origins_mm": [{"x": side_t, "y": max(0.0, depth - thickness_mm), "z": back_origin_z}], + "material_group": "背板", + "recommended_species": "桐木", + "coating_area_m2": 0.0, + "machining": {"holes": 0, "slots": 0, "mortise_tenon": 0, "edge_profile_m": 0.0}, + "confidence": 1.0, + } + ) + updated["structure_questions"] = [ + item for item in updated.get("structure_questions") or [] if item.get("id") != "back_panel" + ] + updated.setdefault("structure_decisions", {})["back_panel"] = { + "present": True, + "thickness_mm": thickness_mm, + "source": "user_confirmed", + } + return updated + + def _resolve_structure(self, analysis: dict, message: str) -> tuple[dict, bool]: + updated = json.loads(json.dumps(analysis, ensure_ascii=False)) + updated = self._apply_user_structure_overrides(updated, message) + unresolved = list(updated.get("structure_questions") or []) + text = str(message or "").strip() + self._apply_user_component_dimensions(updated, text) + if any(item.get("id") == "overall_depth" for item in unresolved): + match = re.search( + r"(?:柜体|产品|整体)?(?:深度|柜深|深)\s*[::]?\s*(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|公分)?", + text, + re.I, + ) + if match: + value = float(match.group(1)) + if str(match.group(2) or "").lower() in {"cm", "厘米", "公分"} or (not match.group(2) and value < 100): + value *= 10.0 + value = min(2000.0, max(80.0, value)) + old_depth = float((updated.get("overall_dimensions_mm") or {}).get("y") or 0) + updated.setdefault("overall_dimensions_mm", {})["y"] = value + updated.setdefault("dimension_evidence", {})["y"] = { + "value": value, + "source": "用户对话确认整体深度", + "confidence": 1.0, + } + for component in updated.get("components") or []: + key = self._component_key(component) + dims = component.get("dimensions_mm") or {} + if self._is_shelf_panel(component) or any(token in key for token in ("顶板", "底板", "侧板", "top panel", "bottom panel", "side panel")): + if old_depth <= 1 or abs(float(dims.get("y") or 0) - old_depth) <= max(5.0, old_depth * 0.05): + dims["y"] = value + declared = int(updated.get("drawer_count") or self._drawer_front_count(updated.get("components") or [])) + updated["components"] = self._ensure_complete_drawer_boxes( + updated.get("components") or [], updated.get("overall_dimensions_mm") or {}, declared, drawer_layout=self._drawer_layout(updated) + ) + updated["structure_questions"] = [item for item in unresolved if item.get("id") != "overall_depth"] + unresolved = list(updated["structure_questions"]) + if any(item.get("id") == "door_mechanism" for item in unresolved): + if any(token in text for token in ("推拉门", "滑门", "移门")): + updated["door_mechanism"] = "sliding" + updated["recognized_hardware"] = [ + item for item in (updated.get("recognized_hardware") or []) + if "铰链" not in str(item.get("name") or "") + ] + updated["structure_questions"] = [item for item in unresolved if item.get("id") != "door_mechanism"] + unresolved = list(updated["structure_questions"]) + elif any(token in text for token in ("平开门", "铰链门")): + updated["door_mechanism"] = "hinged" + updated["structure_questions"] = [item for item in unresolved if item.get("id") != "door_mechanism"] + unresolved = list(updated["structure_questions"]) + if any(item.get("id") == "door_panel_height" for item in unresolved): + match = re.search( + r"(?:推拉门|移门|柜门|门片)(?:的)?(?:高度|高)?\s*[::]?\s*(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|公分)?", + text, + re.I, + ) + if match: + value = float(match.group(1)) + if str(match.group(2) or "").lower() in {"cm", "厘米", "公分"} or (not match.group(2) and value < 100): + value *= 10.0 + overall_height = float((updated.get("overall_dimensions_mm") or {}).get("z") or 0.0) + value = min(max(80.0, value), max(80.0, overall_height)) + door_evidence = { + "leaf_widths": list((updated.get("door_dimensions_mm") or {}).get("leaf_widths") or []), + "leaf_height": value, + "source": "用户对话确认门片高度", + "confidence": 1.0, + } + updated["door_dimensions_mm"] = door_evidence + updated["components"] = self._apply_confirmed_door_dimensions( + updated.get("components") or [], door_evidence + ) + updated["structure_questions"] = [ + item for item in unresolved if item.get("id") != "door_panel_height" + ] + unresolved = list(updated["structure_questions"]) + if any(item.get("id") == "cabinet_carcass" for item in unresolved): + if any(token in text for token in ("补齐基础柜体", "基础柜体结构补齐", "按整体尺寸补齐", "按CAD补齐")): + updated = self._add_inferred_cabinet_carcass(updated) + updated["structure_questions"] = [ + item for item in unresolved if item.get("id") != "cabinet_carcass" + ] + unresolved = list(updated["structure_questions"]) + if any(item.get("id") == "back_panel" for item in unresolved): + if re.search(r"(?:无|不要|没有|不做|取消)背板|背板(?:无|不要|没有|不做)", text): + updated["structure_questions"] = [item for item in unresolved if item.get("id") != "back_panel"] + updated.setdefault("structure_decisions", {})["back_panel"] = { + "present": False, + "source": "user_confirmed", + } + elif "背板" in text and re.search(r"(?:有|加|要|包含|保留|mm|毫米|\d)", text, re.I): + updated = self._add_confirmed_back_panel(updated, self._back_panel_thickness(text)) + estimation_authorized = self._message_authorizes_cad_estimation(text) + if any(item.get("id") == "cad_estimation_consent" for item in unresolved) and estimation_authorized: + low_items = self._low_confidence_dimension_items(updated) + updated["estimation_consent"] = { + "approved": True, + "source": "user_confirmed", + "scope": [str(item.get("label") or item.get("id") or "") for item in low_items], + "approved_at": self._now(), + } + assumption = "用户已明确授权:未直接标注的关键尺寸按CAD装配合理值预估,报价中保留预估标识" + if assumption not in (updated.get("assumptions") or []): + updated.setdefault("assumptions", []).append(assumption) + # Recalculate the gate from current evidence after every answer. This + # prevents one answered question from accidentally clearing newly + # inferred drawer/internal dimensions. + updated["structure_questions"] = self._critical_structure_questions(updated) + return updated, not bool(updated.get("structure_questions")) + + def _structure_answer(self, project: dict) -> str: + analysis = project.get("analysis") or {} + rows = [] + for item in (analysis.get("components") or [])[:20]: + dims = item["dimensions_mm"] + rows.append( + f"| {item['part_id']} | {item['name']} | {item['quantity']} | " + f"{dims['x']:.0f}×{dims['y']:.0f}×{dims['z']:.0f} | {item['material_group']} |" + ) + questions = analysis.get("structure_questions") or [] + question_lines = "\n".join(f"- **{item['label']}**:{item['reason']}。{item['prompt']}。" for item in questions) + uncertainty_lines = "\n".join(f"- {x}" for x in analysis.get("uncertainties") or []) + extra = f"\n\n其他识别提示:\n{uncertainty_lines}" if uncertainty_lines else "" + first_question = str((questions[0] if questions else {}).get("id") or "") + low_question = next((item for item in questions if item.get("id") == "cad_estimation_consent"), {}) + if low_question: + low_rows = [] + for item in (low_question.get("items") or [])[:15]: + value = item.get("value") + if isinstance(value, (int, float)): + value = f"{float(value):.0f}mm" if float(value) > 0 else "未取得" + low_rows.append( + f"| {item.get('label') or item.get('id')} | {value} | " + f"{float(item.get('confidence') or 0):.0%} | {item.get('reason') or '无直接标注'} |" + ) + hidden_count = max(0, len(low_question.get("items") or []) - len(low_rows)) + extra += ( + "\n\n#### 低置信尺寸清单\n\n" + "| 尺寸项 | 当前识别/预估值 | 置信度 | 依据 |\n| --- | --- | ---: | --- |\n" + + "\n".join(low_rows) + + (f"\n\n另有{hidden_count}项已保存在完整检查点中。" if hidden_count else "") + + "\n\n未授权前,这些值不会被当成精确尺寸进入报价。" + ) + if first_question == "overall_depth": + action_title = "确认整体深度" + actions = [ + {"label": "柜深40cm", "message": "【技能路由:ai-solid-wood-cost】柜深40cm", "intent": "confirm_structure"}, + {"label": "柜深45cm", "message": "【技能路由:ai-solid-wood-cost】柜深45cm", "intent": "confirm_structure"}, + {"label": "柜深50cm", "message": "【技能路由:ai-solid-wood-cost】柜深50cm", "intent": "confirm_structure"}, + ] + elif first_question == "door_mechanism": + action_title = "确认柜门方式" + actions = [ + {"label": "推拉门", "message": "【技能路由:ai-solid-wood-cost】柜门是推拉门,不使用铰链", "intent": "confirm_structure"}, + {"label": "平开门", "message": "【技能路由:ai-solid-wood-cost】柜门是平开门,使用铰链", "intent": "confirm_structure"}, + ] + elif first_question == "door_panel_height": + action_title = "确认移门高度" + actions = [ + {"label": "门高54cm", "message": "【技能路由:ai-solid-wood-cost】推拉门高度54cm", "intent": "confirm_structure"}, + {"label": "门高50cm", "message": "【技能路由:ai-solid-wood-cost】推拉门高度50cm", "intent": "confirm_structure"}, + {"label": "自定义", "message": "【技能路由:ai-solid-wood-cost】推拉门高度:", "intent": "confirm_structure"}, + ] + elif first_question == "cabinet_carcass": + action_title = "补齐基础柜体结构" + actions = [ + {"label": "按CAD补齐并复核", "message": "【技能路由:ai-solid-wood-cost】同意按整体尺寸补齐基础柜体结构,生成CAD后与原图复核", "intent": "confirm_structure"}, + {"label": "填写部件尺寸", "message": "【技能路由:ai-solid-wood-cost】我填写缺失部件尺寸:左侧板=;右侧板=;柜体底板=", "intent": "confirm_structure"}, + ] + elif first_question == "cad_estimation_consent": + action_title = "选择低置信尺寸处理方式" + actions = [ + {"label": "单张补传尺寸图", "message": "【技能路由:ai-solid-wood-cost】我要单张补传更完整的尺寸图", "intent": "supplement_dimension"}, + {"label": "填写关键尺寸", "message": "【技能路由:ai-solid-wood-cost】我填写关键尺寸:", "intent": "confirm_structure"}, + {"label": "同意按CAD预估", "message": "【技能路由:ai-solid-wood-cost】同意对未标注尺寸按CAD装配合理值预估", "intent": "confirm_structure"}, + ] + else: + action_title = "确认背板结构" + actions = [ + {"label": "有9mm背板", "message": "【技能路由:ai-solid-wood-cost】有背板,厚度9mm", "intent": "confirm_structure"}, + {"label": "有12mm背板", "message": "【技能路由:ai-solid-wood-cost】有背板,厚度12mm", "intent": "confirm_structure"}, + {"label": "确认无背板", "message": "【技能路由:ai-solid-wood-cost】确认无背板", "intent": "confirm_structure"}, + ] + return ( + f"### {self._display_product_name(analysis)}:BOM草案已拆分\n\n" + "> **当前步骤:** 先确认缺失的结构,未确认前不会生成最终报价。\n\n" + "| 部件编号 | 部件 | 数量 | 单件尺寸(mm) | 材质组 |\n| --- | --- | ---: | --- | --- |\n" + + "\n".join(rows) + + f"\n\n待确认:\n{question_lines}{extra}\n\n" + "```openclaw-actions\n" + + json.dumps( + { + "openclaw_actions": True, + "type": "quick_replies", + "title": action_title, + "actions": actions, + }, + ensure_ascii=False, + indent=2, + ) + + "\n```" + ) + + def _save_bom_draft(self, project_id: str, analysis: dict) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM bom_components WHERE project_id=?", (project_id,)) + for item in analysis["components"]: + dims = item["dimensions_mm"] + qty = int(item["quantity"]) + net_volume = self._component_net_geometry(item)[0] * qty + area = item.get("coating_area_m2") or ( + 2 * (dims["x"] * dims["y"] + dims["x"] * dims["z"] + dims["y"] * dims["z"]) * qty / 1_000_000 + ) + conn.execute( + """ + INSERT INTO bom_components(project_id,part_id,part_name,quantity,dimensions_json,origins_json, + material_group,selected_species,net_volume_m3,coating_area_m2,confidence) + VALUES(?,?,?,?,?,?,?,?,?,?,?) + """, + ( + project_id, item["part_id"], item["name"], qty, + json.dumps(dims), json.dumps(item.get("origins_mm") or []), + item["material_group"], "", net_volume, area, item["confidence"], + ), + ) + + def _material_structure_answer(self, project: dict) -> str: + analysis = project.get("analysis") or {} + rows = [] + for item in (analysis.get("components") or [])[:20]: + dims = item["dimensions_mm"] + rows.append( + f"| {item['part_id']} | {item['name']} | {item['quantity']} | " + f"{dims['x']:.0f}×{dims['y']:.0f}×{dims['z']:.0f} | {item['material_group']} |" + ) + decision = (analysis.get("structure_decisions") or {}).get("back_panel") + if decision and decision.get("present"): + structure_note = f"背板:已确认有,厚度{float(decision.get('thickness_mm') or 0):g}mm" + elif decision and decision.get("present") is False: + structure_note = "背板:已确认无" + else: + structure_note = "关键结构检查已通过" + return ( + f"### {self._display_product_name(analysis)}拆件完成\n\n" + f"> **整体尺寸:** {analysis.get('overall_dimensions_mm', {}).get('x', 0):.0f} × " + f"{analysis.get('overall_dimensions_mm', {}).get('y', 0):.0f} × " + f"{analysis.get('overall_dimensions_mm', {}).get('z', 0):.0f} mm \n" + f"> **当前步骤:** 先选择产品用材结构。未选择前不会进入木种、板厚或饰面确认。\n\n" + "| 部件编号 | 部件 | 数量 | 单件尺寸(mm) | 材质组 |\n| --- | --- | ---: | --- | --- |\n" + + "\n".join(rows) + + f"\n\n结构确认:{structure_note}。\n\n" + "- **纯实木:** 所有木质部件按同一实木材种计价,包含背板和抽屉底板。\n" + "- **主体实木+辅材:** 主体与背板、抽屉底板分别选择实木材种。\n" + "- **板式:** 下一步单独选择密度板、刨花板或实木多层板,不与板厚、环保和封边混选。\n\n" + "- **实木+金属:** 木质部件与金属框架分别确认材种、型材、壁厚、连接和表面处理。\n" + "- **板材+金属:** 板材规格与金属规格分别确认,任何工艺都只按实际驱动计费。\n" + "- **全金属:** 跳过木材和木器漆,只进入金属BOM与工艺路线。\n\n" + "```openclaw-actions\n" + + json.dumps( + { + "openclaw_actions": True, + "type": "quick_replies", + "title": "选择产品用材结构", + "actions": [ + {"label": "纯实木", "message": "【技能路由:ai-solid-wood-cost】选择纯实木结构,下一步选择木种", "intent": "confirm_material_structure"}, + {"label": "主体实木+辅材", "message": "【技能路由:ai-solid-wood-cost】选择主体实木加辅材结构,下一步分别选择材种", "intent": "confirm_material_structure"}, + {"label": "板式家具", "message": "【技能路由:ai-solid-wood-cost】选择板式家具,下一步选择板材种类", "intent": "confirm_material_structure"}, + {"label": "实木+金属", "message": "【技能路由:ai-solid-wood-cost】选择实木加金属结构,下一步分别确认", "intent": "confirm_material_structure"}, + {"label": "板材+金属", "message": "【技能路由:ai-solid-wood-cost】选择板材加金属结构,下一步分别确认", "intent": "confirm_material_structure"}, + {"label": "全金属", "message": "【技能路由:ai-solid-wood-cost】选择全金属家具结构,下一步确认金属部件", "intent": "confirm_material_structure"}, + ], + }, + ensure_ascii=False, + indent=2, + ) + + "\n```" + ) + + def _material_answer(self, project: dict) -> str: + analysis = project.get("analysis") or {} + selections = project.get("selections") or {} + structure = str(selections.get("material_structure") or "") + if not structure: + return self._material_structure_answer(project) + if structure in {"engineered_panel_all", "engineered_panel_metal"}: + title = "选择板材种类" + guidance = "本步只选择板材种类;板厚、环保等级、采购状态、饰面和封边会在后续分别确认。" + actions = [ + {"label": "密度板MDF", "message": "【技能路由:ai-solid-wood-cost】全部木质部件使用密度板MDF", "intent": "confirm_material"}, + {"label": "刨花板", "message": "【技能路由:ai-solid-wood-cost】全部木质部件使用刨花板", "intent": "confirm_material"}, + {"label": "实木多层板", "message": "【技能路由:ai-solid-wood-cost】全部木质部件使用实木多层板(胶合板)", "intent": "confirm_material"}, + ] + elif structure in {"solid_wood_all", "solid_wood_metal"}: + title = "为纯实木选择木种" if structure == "solid_wood_all" else "为木质部件选择木种" + guidance = ( + "本选择会应用到所有木质部件,包括背板和抽屉底板;油漆仍在下一步单独选择。" + if structure == "solid_wood_all" + else "本选择只应用到木质部件;金属部件的材质、规格、壁厚和表面处理会在后续逐项确认。" + ) + actions = [ + {"label": "全部白橡木", "message": "【技能路由:ai-solid-wood-cost】纯实木家具全部木质部件使用白橡木", "intent": "confirm_material"}, + {"label": "全部黑胡桃", "message": "【技能路由:ai-solid-wood-cost】纯实木家具全部木质部件使用黑胡桃木", "intent": "confirm_material"}, + {"label": "全部橡胶木", "message": "【技能路由:ai-solid-wood-cost】纯实木家具全部木质部件使用橡胶木", "intent": "confirm_material"}, + {"label": "全部松木", "message": "【技能路由:ai-solid-wood-cost】纯实木家具全部木质部件使用松木", "intent": "confirm_material"}, + ] + elif structure == "solid_wood_aux": + title = "分别选择主体与辅材木种" + guidance = "背板、抽屉底板和内衬按辅材计价,其余部件按主体材种计价;油漆仍在下一步单独选择。" + actions = [ + {"label": "白橡+桐木辅材", "message": "【技能路由:ai-solid-wood-cost】主体白橡木,背板、抽屉底板和内衬使用桐木", "intent": "confirm_material"}, + {"label": "黑胡桃+桐木", "message": "【技能路由:ai-solid-wood-cost】主体黑胡桃木,背板、抽屉底板和内衬使用桐木", "intent": "confirm_material"}, + {"label": "橡胶木+桐木", "message": "【技能路由:ai-solid-wood-cost】主体橡胶木,背板、抽屉底板和内衬使用桐木", "intent": "confirm_material"}, + ] + else: + return self._metal_component_scope_answer(project) + return ( + "### 用材结构已保存\n\n" + f"> **当前步骤:** {title}。\n\n" + f"{guidance}\n\n" + "```openclaw-actions\n" + + json.dumps( + { + "openclaw_actions": True, + "type": "quick_replies", + "title": title, + "actions": actions, + }, + ensure_ascii=False, + indent=2, + ) + + "\n```" + ) + + def _metal_component_scope_answer(self, project: dict) -> str: + selections = project.get("selections") or {} + analysis = project.get("analysis") or {} + detected = self._metal_components(analysis, {**selections, "metal_component_keywords": []}) + names = "、".join(f"{item.get('part_id')} {item.get('name')}×{int(item.get('quantity') or 1)}" for item in detected) + if not names: + names = "当前BOM名称中未发现明确金属词,需要用户确认部件" + return ( + "### 确认金属部件范围\n\n" + "> **当前步骤:** 只确认哪些BOM部件是金属;不会在本步选择材质或壁厚。\n\n" + f"AI候选:{names}\n\n" + "如候选不完整,可直接回复:`金属部件:支腿、横撑、底座`。木板、木腿等其余部件会继续按木材/板材路线计价。\n\n" + + self._quick_replies( + "确认金属部件", + [ + {"label": "按AI候选", "message": "【技能路由:ai-solid-wood-cost】确认按AI识别的金属部件", "intent": "confirm_metal_scope"}, + {"label": "框架+支腿+横撑", "message": "【技能路由:ai-solid-wood-cost】金属部件:框架、支腿、横撑、底座", "intent": "confirm_metal_scope"}, + {"label": "全部为金属", "message": "【技能路由:ai-solid-wood-cost】确认全部部件为金属", "intent": "confirm_metal_scope"}, + ], + ) + ) + + def _metal_material_answer(self, project: dict) -> str: + return ( + "### 选择金属材质\n\n" + "> **当前步骤:** 本步只选择金属牌号。型材形状、截面尺寸和壁厚会在后续分别确认。\n\n" + + self._quick_replies( + "选择金属材质", + [ + {"label": "Q235碳钢", "message": "【技能路由:ai-solid-wood-cost】金属使用Q235碳钢黑管", "intent": "confirm_metal_material"}, + {"label": "Q235镀锌钢", "message": "【技能路由:ai-solid-wood-cost】金属使用Q235镀锌钢管", "intent": "confirm_metal_material"}, + {"label": "201不锈钢", "message": "【技能路由:ai-solid-wood-cost】金属使用201不锈钢", "intent": "confirm_metal_material"}, + {"label": "304不锈钢", "message": "【技能路由:ai-solid-wood-cost】金属使用304不锈钢", "intent": "confirm_metal_material"}, + {"label": "6063铝合金", "message": "【技能路由:ai-solid-wood-cost】金属使用6063铝合金型材", "intent": "confirm_metal_material"}, + ], + ) + ) + + def _metal_profile_answer(self, project: dict) -> str: + return ( + "### 选择金属型材截面\n\n" + "> **当前步骤:** 本步只选择形状和外径/外宽;壁厚下一步单独确认。承重设计仍需结构工程校核。\n\n" + + self._quick_replies( + "选择型材截面", + [ + {"label": "15×15方管", "message": "【技能路由:ai-solid-wood-cost】金属型材使用15×15mm方管", "intent": "confirm_metal_profile"}, + {"label": "20×20方管", "message": "【技能路由:ai-solid-wood-cost】金属型材使用20×20mm方管", "intent": "confirm_metal_profile"}, + {"label": "25×25方管", "message": "【技能路由:ai-solid-wood-cost】金属型材使用25×25mm方管", "intent": "confirm_metal_profile"}, + {"label": "30×15矩形管", "message": "【技能路由:ai-solid-wood-cost】金属型材使用30×15mm矩形管", "intent": "confirm_metal_profile"}, + {"label": "40×20矩形管", "message": "【技能路由:ai-solid-wood-cost】金属型材使用40×20mm矩形管", "intent": "confirm_metal_profile"}, + {"label": "40×40方管", "message": "【技能路由:ai-solid-wood-cost】金属型材使用40×40mm方管", "intent": "confirm_metal_profile"}, + {"label": "Φ16圆管", "message": "【技能路由:ai-solid-wood-cost】金属型材使用直径16mm圆管", "intent": "confirm_metal_profile"}, + ], + ) + ) + + def _metal_wall_thickness_answer(self, project: dict) -> str: + selections = project.get("selections") or {} + profile = str(selections.get("metal_profile_display") or "已选型材") + return ( + "### 确认金属壁厚\n\n" + f"> **已选截面:** {profile} \n" + "> **当前步骤:** 壁厚必须由用户确认,系统不会从外观图静默猜测。0.5/0.6mm仅作为装饰或轻载家具管候选;承重件必须另做结构安全校核。\n\n" + + self._quick_replies( + "选择壁厚", + [ + {"label": "0.5mm(轻载)", "message": "【技能路由:ai-solid-wood-cost】金属管壁厚0.5mm", "intent": "confirm_metal_wall"}, + {"label": "0.6mm(轻载)", "message": "【技能路由:ai-solid-wood-cost】金属管壁厚0.6mm", "intent": "confirm_metal_wall"}, + {"label": "0.8mm", "message": "【技能路由:ai-solid-wood-cost】金属管壁厚0.8mm", "intent": "confirm_metal_wall"}, + {"label": "1.0mm", "message": "【技能路由:ai-solid-wood-cost】金属管壁厚1.0mm", "intent": "confirm_metal_wall"}, + {"label": "1.2mm", "message": "【技能路由:ai-solid-wood-cost】金属管壁厚1.2mm", "intent": "confirm_metal_wall"}, + {"label": "1.5mm", "message": "【技能路由:ai-solid-wood-cost】金属管壁厚1.5mm", "intent": "confirm_metal_wall"}, + ], + ) + ) + + def _metal_joining_answer(self, project: dict) -> str: + analysis = project.get("analysis") or {} + selections = project.get("selections") or {} + pieces = sum(int(item.get("quantity") or 1) for item in self._metal_components(analysis, selections)) + suggested = max(0, pieces * 2 - 2) if pieces else 0 + material_code = str(selections.get("metal_material_code") or "") + if material_code in {"SS201", "SS304"}: + weld_action = {"label": "TIG间断焊", "message": f"【技能路由:ai-solid-wood-cost】使用TIG间断焊,{suggested}个接头", "intent": "confirm_metal_joining"} + elif material_code == "AL6063": + weld_action = {"label": "铝TIG间断焊", "message": f"【技能路由:ai-solid-wood-cost】使用铝合金TIG间断焊,{suggested}个接头", "intent": "confirm_metal_joining"} + else: + weld_action = {"label": "CO2间断焊", "message": f"【技能路由:ai-solid-wood-cost】使用CO2间断焊,{suggested}个接头", "intent": "confirm_metal_joining"} + return ( + "### 选择金属连接工艺\n\n" + "> **当前步骤:** 连接方式单独确认。家具管默认按间断焊估算,只有用户明确选择满焊才按整个截面周长收费;螺栓拆装不产生焊接费。\n\n" + f"按当前金属BOM建议先按 **{suggested}个接头** 估算,可直接回复实际数量和焊法,例如 `CO2间断焊,12个接头`。\n\n" + + self._quick_replies( + "选择连接工艺", + [ + weld_action, + {"label": "满焊", "message": f"【技能路由:ai-solid-wood-cost】使用满焊,{suggested}个接头", "intent": "confirm_metal_joining"}, + {"label": "螺栓拆装", "message": f"【技能路由:ai-solid-wood-cost】金属使用螺栓拆装,{suggested}个接头,无焊接", "intent": "confirm_metal_joining"}, + ], + ) + ) + + def _hardware_answer(self, project: dict) -> str: + analysis = project.get("analysis") or {} + doors = self._door_panel_count(analysis.get("components") or []) + drawers = self._drawer_front_count(analysis.get("components") or []) + mechanism = str(analysis.get("door_mechanism") or "unknown") + mechanism_label = {"sliding": "推拉门(铰链为0)", "hinged": "平开门", "none": "无门"}.get(mechanism, "门型待确认") + return ( + "### 选择五金档次\n\n" + f"> **结构识别:** {doors}扇{mechanism_label}、{drawers}个完整抽屉。 \n" + "> **当前步骤:** 五金按已确认门型、每个抽屉一副导轨、拉手和连接件逐项核算;推拉门不计铰链。\n\n" + + self._quick_replies( + "选择五金档次", + [ + {"label": "基础量产五金", "message": "【技能路由:ai-solid-wood-cost】使用基础量产五金", "intent": "confirm_hardware"}, + {"label": "标准缓冲五金", "message": "【技能路由:ai-solid-wood-cost】使用标准缓冲五金", "intent": "confirm_hardware"}, + {"label": "品牌升级五金", "message": "【技能路由:ai-solid-wood-cost】使用品牌升级五金", "intent": "confirm_hardware"}, + ], + ) + ) + + def _metal_finish_answer(self, project: dict) -> str: + code = str((project.get("selections") or {}).get("metal_material_code") or "") + if code in {"SS201", "SS304"}: + actions = [ + {"label": "不锈钢拉丝", "message": "【技能路由:ai-solid-wood-cost】金属表面使用不锈钢拉丝", "intent": "confirm_metal_finish"}, + {"label": "粉末喷涂", "message": "【技能路由:ai-solid-wood-cost】金属表面使用粉末喷涂", "intent": "confirm_metal_finish"}, + {"label": "原表面", "message": "【技能路由:ai-solid-wood-cost】金属保留原表面,不再加工", "intent": "confirm_metal_finish"}, + ] + elif code == "AL6063": + actions = [ + {"label": "阳极氧化", "message": "【技能路由:ai-solid-wood-cost】铝合金使用阳极氧化", "intent": "confirm_metal_finish"}, + {"label": "粉末喷涂", "message": "【技能路由:ai-solid-wood-cost】金属表面使用粉末喷涂", "intent": "confirm_metal_finish"}, + {"label": "原表面", "message": "【技能路由:ai-solid-wood-cost】金属保留原表面,不再加工", "intent": "confirm_metal_finish"}, + ] + elif code == "Q235_GALV": + actions = [ + {"label": "粉末喷涂", "message": "【技能路由:ai-solid-wood-cost】金属表面使用粉末喷涂", "intent": "confirm_metal_finish"}, + {"label": "保留镀锌面", "message": "【技能路由:ai-solid-wood-cost】金属保留原表面,不再加工", "intent": "confirm_metal_finish"}, + ] + else: + actions = [ + {"label": "磷化+粉末喷涂", "message": "【技能路由:ai-solid-wood-cost】金属表面使用除油磷化加粉末喷涂", "intent": "confirm_metal_finish"}, + {"label": "防锈底漆+面漆", "message": "【技能路由:ai-solid-wood-cost】金属表面使用防锈底漆加面漆", "intent": "confirm_metal_finish"}, + {"label": "原表面", "message": "【技能路由:ai-solid-wood-cost】金属保留原表面,不再加工", "intent": "confirm_metal_finish"}, + ] + return ( + "### 选择金属表面处理\n\n" + "> **当前步骤:** 金属涂装与木器饰面独立。按金属外露面积计费,不按产品总成本加比例。\n\n" + + self._quick_replies("选择金属表面处理", actions) + ) + + def _parse_selection(self, message: str, project: dict) -> dict: + text = str(message or "") + selections = dict(project.get("selections") or {}) + if "板材加金属" in text or "板材+金属" in text or "板材+金属" in text: + selections["material_structure"] = "engineered_panel_metal" + elif "实木加金属" in text or "实木+金属" in text or "实木+金属" in text: + selections["material_structure"] = "solid_wood_metal" + elif "全金属" in text or "全部部件为金属" in text: + selections["material_structure"] = "metal_all" + selections["material_family"] = "metal" + selections["metal_component_scope"] = "all" + elif "纯实木" in text: + selections["material_structure"] = "solid_wood_all" + elif "主体实木加辅材" in text or "主体实木+辅材" in text or "主体实木+辅材" in text: + selections["material_structure"] = "solid_wood_aux" + elif "全板式" in text or "板式家具" in text: + selections["material_structure"] = "engineered_panel_all" + panel_code = self._panel_code_from_text(text) + if panel_code: + panel_name = str(self.PANEL_MATERIALS[panel_code]["name"]) + structure = str(selections.get("material_structure") or "") + selections.update( + { + "material_structure": "engineered_panel_metal" if structure == "engineered_panel_metal" else "engineered_panel_all", + "material_family": "engineered_panel", + "panel_code": panel_code, + "main_species": panel_name, + "aux_species": panel_name, + "all_same": True, + } + ) + prices = self._prices() + mentioned: list[str] = [] + for price in prices: + name = str(price["species_name"]) + base = re.split(r"[((]", name)[0] + if name in text or (len(base) >= 2 and base in text): + mentioned.append(name) + if "白橡木" in text and not any("白橡" in x for x in mentioned): + mentioned.append("白橡(北美FAS)") + if "黑胡桃木" in text and not any("黑胡桃" in x for x in mentioned): + mentioned.append("黑胡桃(北美FAS)") + if "按推荐" in text and not mentioned: + mentioned = ["白橡(北美FAS)", "桐木"] + if mentioned and not panel_code: + main = next((x for x in mentioned if "桐木" not in x), mentioned[0]) + auxiliary = next((x for x in mentioned if "桐木" in x), main) + current_structure = str(selections.get("material_structure") or "") + if current_structure == "solid_wood_metal": + selections["material_structure"] = "solid_wood_metal" + auxiliary = main + elif "纯实木" in text or ("全部" in text and len(mentioned) == 1): + selections["material_structure"] = "solid_wood_all" + auxiliary = main + elif not selections.get("material_structure"): + selections["material_structure"] = "solid_wood_aux" if len(mentioned) > 1 or "主体" in text else "solid_wood_all" + selections["main_species"] = main + selections["aux_species"] = auxiliary + selections["all_same"] = selections.get("material_structure") in {"solid_wood_all", "solid_wood_metal"} + selections.pop("panel_code", None) + selections["material_family"] = "solid_wood" + if "按AI识别的金属部件" in text or "按AI候选" in text: + selections["metal_component_scope"] = "ai_detected" + selections.pop("metal_component_keywords", None) + custom_metal = re.search(r"金属部件\s*[::]\s*([^,。;;\n]+(?:[、,,][^。;;\n]+)*)", text) + if custom_metal: + keywords = [x.strip() for x in re.split(r"[、,,]", custom_metal.group(1)) if x.strip()] + selections["metal_component_scope"] = "custom" + selections["metal_component_keywords"] = keywords[:20] + if "全部部件为金属" in text: + selections["metal_component_scope"] = "all" + metal_code = self._metal_code_from_text(text) + if metal_code: + selections["metal_material_code"] = metal_code + selections["metal_material_name"] = self.METAL_MATERIALS[metal_code]["name"] + rect_match = re.search(r"(\d{1,3}(?:\.\d+)?)\s*[×xX*]\s*(\d{1,3}(?:\.\d+)?)\s*(?:mm|毫米)?\s*(方管|矩形管)", text, re.I) + round_match = re.search(r"(?:直径|[ΦφØ])\s*(\d{1,3}(?:\.\d+)?)\s*(?:mm|毫米)?\s*(?:圆管)?", text, re.I) + if rect_match: + width = float(rect_match.group(1)) + height = float(rect_match.group(2)) + shape = "square_tube" if abs(width - height) < 0.001 and rect_match.group(3) == "方管" else "rect_tube" + selections.update( + { + "metal_profile_shape": shape, + "metal_profile_width_mm": width, + "metal_profile_height_mm": height, + "metal_profile_diameter_mm": 0.0, + "metal_profile_display": f"{width:g}×{height:g}mm{'方管' if shape == 'square_tube' else '矩形管'}", + } + ) + elif round_match: + diameter = float(round_match.group(1)) + selections.update( + { + "metal_profile_shape": "round_tube", + "metal_profile_width_mm": 0.0, + "metal_profile_height_mm": 0.0, + "metal_profile_diameter_mm": diameter, + "metal_profile_display": f"Φ{diameter:g}mm圆管", + } + ) + wall_match = re.search(r"(?:管)?壁厚\s*(\d(?:\.\d+)?)\s*(?:mm|毫米)", text, re.I) + if wall_match: + selections["metal_wall_thickness_mm"] = float(wall_match.group(1)) + joint_match = re.search(r"(\d{1,4})\s*个接头", text) + if "螺栓拆装" in text or "无焊接" in text: + selections["metal_joining"] = "bolted" + selections["metal_weld_coverage"] = 0.0 + selections["metal_grind_coverage"] = 0.0 + selections["metal_joint_count"] = int(joint_match.group(1)) if joint_match else int(selections.get("metal_joint_count") or 0) + elif "CO2" in text.upper() or "MAG焊" in text.upper(): + selections["metal_joining"] = "weld_co2" + if joint_match: + selections["metal_joint_count"] = int(joint_match.group(1)) + elif "TIG" in text.upper() or "氩弧焊" in text: + selections["metal_joining"] = "weld_aluminum" if selections.get("metal_material_code") == "AL6063" else "weld_tig" + if joint_match: + selections["metal_joint_count"] = int(joint_match.group(1)) + elif "满焊" in text: + material_code = str(selections.get("metal_material_code") or "") + selections["metal_joining"] = "weld_aluminum" if material_code == "AL6063" else ("weld_tig" if material_code in {"SS201", "SS304"} else "weld_co2") + if joint_match: + selections["metal_joint_count"] = int(joint_match.group(1)) + if selections.get("metal_joining") in {"weld_co2", "weld_tig", "weld_aluminum"}: + if "满焊" in text: + selections["metal_weld_coverage"] = 1.0 + selections["metal_grind_coverage"] = 1.0 + elif "双面焊" in text: + selections["metal_weld_coverage"] = 0.5 + selections["metal_grind_coverage"] = 0.7 + else: + # Furniture tube joints are normally intermittent/partial welds. + # A plain "CO2焊" must not silently become a full perimeter weld. + selections["metal_weld_coverage"] = 0.35 + selections["metal_grind_coverage"] = 0.5 + if "除油磷化" in text and "粉末喷涂" in text: + selections["metal_finish"] = "phosphating_powder" + elif "粉末喷涂" in text: + selections["metal_finish"] = "powder_coat" + elif "防锈底漆" in text or "金属面漆" in text: + selections["metal_finish"] = "wet_paint" + elif "不锈钢拉丝" in text: + selections["metal_finish"] = "stainless_brush" + elif "阳极氧化" in text: + selections["metal_finish"] = "aluminum_anodize" + elif "金属保留原表面" in text or "金属原表面" in text: + selections["metal_finish"] = "raw" + thickness_match = re.search(r"(?:统一板厚|板厚|板材厚度)\s*(\d{1,2}(?:\.\d+)?)\s*(?:mm|毫米)", text, re.I) + centimeter_thickness_match = re.search( + r"(?:统一板厚|板厚|板材厚度|厚度)\s*(1\.2)\s*(?:cm|厘米|公分)?(?:厚)?", + text, + re.I, + ) + grouped_thickness = re.search( + r"主体\s*(\d{1,2}(?:\.\d+)?)\s*(?:mm|毫米)?.{0,20}?(?:背板|抽屉底板).{0,8}?(\d(?:\.\d+)?)\s*(?:mm|毫米)?", + text, + re.I, + ) + if grouped_thickness: + selections["panel_thickness_mode"] = "grouped" + selections["panel_main_thickness_mm"] = float(grouped_thickness.group(1)) + selections["panel_thin_thickness_mm"] = float(grouped_thickness.group(2)) + elif thickness_match: + selections["panel_thickness_mode"] = "uniform" + selections["panel_thickness_mm"] = float(thickness_match.group(1)) + elif centimeter_thickness_match: + selections["panel_thickness_mode"] = "uniform" + selections["panel_thickness_mm"] = float(centimeter_thickness_match.group(1)) * 10.0 + elif "按BOM" in text.upper() or "按拆件" in text or "按识别" in text: + selections["panel_thickness_mode"] = "bom" + if "基础量产五金" in text or "基础五金" in text: + selections["hardware_grade"] = "basic" + elif "标准缓冲五金" in text or "标准五金" in text: + selections["hardware_grade"] = "standard" + elif "品牌升级五金" in text or "品牌五金" in text: + selections["hardware_grade"] = "premium" + grade_match = re.search(r"(ENF|E0|E1)(?![A-Za-z0-9])", text, re.I) + if grade_match: + selections["emission_grade"] = grade_match.group(1).upper() + if selections.get("panel_code") == "MDF_UNGRADED": + selections.update( + { + "panel_code": "MDF", + "main_species": self.PANEL_MATERIALS["MDF"]["name"], + "aux_species": self.PANEL_MATERIALS["MDF"]["name"], + } + ) + elif "无环保等级" in text or "无等级" in text or "不分等级" in text: + selections.update( + { + "emission_grade": "UNGRADED", + "panel_code": "MDF_UNGRADED", + "main_species": self.PANEL_MATERIALS["MDF_UNGRADED"]["name"], + "aux_species": self.PANEL_MATERIALS["MDF_UNGRADED"]["name"], + } + ) + if "成品双饰面" in text or "成品饰面板" in text or "免漆板" in text: + selections["panel_purchase_state"] = "prefinished" + elif "已贴木皮" in text or "预贴木皮" in text: + selections["panel_purchase_state"] = "preveneered" + elif "裸板" in text or "厂内贴面" in text: + selections["panel_purchase_state"] = "bare" + if "实木封边" in text: + selections["edge_material"] = "SOLID_WOOD" + selections.pop("edge_code", None) + selections.pop("edge_thickness_mm", None) + elif "ABS" in text.upper(): + selections["edge_material"] = "ABS" + selections.pop("edge_code", None) + selections.pop("edge_thickness_mm", None) + elif "PVC" in text.upper(): + selections["edge_material"] = "PVC" + selections.pop("edge_code", None) + selections.pop("edge_thickness_mm", None) + edge_thickness = re.search(r"(?:封边(?:条)?(?:厚度)?|PVC|ABS)\D{0,8}(0\.5|1(?:\.0)?|2(?:\.0)?)\s*(?:mm|毫米)", text, re.I) + if not edge_thickness and re.search(r"PVC|ABS|实木封边", text, re.I): + edge_thickness = re.search( + r"(? 0: + selections["edge_thickness_mm"] = edge_thickness + if edge_material in {"PVC", "ABS"} and edge_thickness > 0: + thickness_key = "0_5" if edge_thickness <= 0.75 else ("2" if edge_thickness >= 1.5 else "1") + selections["edge_code"] = f"{edge_material}_{thickness_key}" + return selections + + @staticmethod + def _material_ready(selections: dict) -> bool: + structure = str(selections.get("material_structure") or "") + if structure in {"engineered_panel_all", "engineered_panel_metal"}: + return bool(selections.get("panel_code") and selections.get("main_species")) + if structure in {"solid_wood_all", "solid_wood_metal"}: + return bool(selections.get("main_species") and selections.get("all_same")) + if structure == "solid_wood_aux": + return bool(selections.get("main_species") and selections.get("aux_species")) + if structure == "metal_all": + return True + return False + + def _metal_scope_ready(self, project: dict, selections: dict) -> bool: + scope = str(selections.get("metal_component_scope") or "") + if self._is_all_metal_selection(selections) or scope == "all": + return bool((project.get("analysis") or {}).get("components")) + if scope not in {"ai_detected", "custom"}: + return False + return bool(self._metal_components(project.get("analysis") or {}, selections)) + + @staticmethod + def _panel_specs_ready(selections: dict) -> bool: + return bool( + selections.get("panel_thickness_mode") in {"bom", "uniform", "grouped"} + and selections.get("emission_grade") in {"UNGRADED", "E1", "E0", "ENF"} + and selections.get("panel_purchase_state") in {"bare", "prefinished", "preveneered"} + and selections.get("edge_code") in {"PVC_0_5", "PVC_1", "PVC_2", "ABS_0_5", "ABS_1", "ABS_2", "SOLID_WOOD"} + and selections.get("edge_scope") in {"exposed_edges", "all_edges", "custom"} + and (selections.get("edge_scope") != "custom" or float(selections.get("custom_edge_length_m") or 0) > 0) + ) + + @staticmethod + def _quick_replies(title: str, actions: list[dict]) -> str: + return ( + "```openclaw-actions\n" + + json.dumps( + {"openclaw_actions": True, "type": "quick_replies", "title": title, "actions": actions}, + ensure_ascii=False, + indent=2, + ) + + "\n```" + ) + + def _active_special_operation_prices(self) -> tuple[dict, dict[str, dict]]: + with self._connect() as conn: + version_row = conn.execute( + "SELECT * FROM special_operation_price_versions WHERE is_active=1 ORDER BY version_id DESC LIMIT 1" + ).fetchone() + if not version_row: + raise SolidWoodCostError("1688特殊操作价格版本未初始化") + rows = conn.execute( + "SELECT * FROM special_operation_prices WHERE version_id=? ORDER BY price_id", + (int(version_row["version_id"]),), + ).fetchall() + return dict(version_row), {str(row["operation_code"]): dict(row) for row in rows} + + @classmethod + def _factory_quote_layer(cls, manufacturing_cost: float, selections: dict) -> dict: + """Build the sell-price layer once, without putting it back into the cost ledger.""" + manufacturing_cost = round(max(0.0, float(manufacturing_cost or 0)), 2) + material_structure = str(selections.get("material_structure") or "") + pure_solid_wood = material_structure == "solid_wood_all" + profit_rate = ( + cls.PURE_SOLID_WOOD_FACTORY_PROFIT_RATE + if pure_solid_wood + else cls.NON_PURE_SOLID_WOOD_FACTORY_PROFIT_RATE + ) + profit_amount = round(manufacturing_cost * profit_rate, 2) + before_output_tax = round(manufacturing_cost + profit_amount, 2) + output_tax_amount = round(before_output_tax * cls.FACTORY_OUTPUT_TAX_RATE, 2) + bom_factory_quote = round(before_output_tax + output_tax_amount, 2) + return { + "manufacturing_cost": manufacturing_cost, + "material_structure": material_structure, + "pure_solid_wood": pure_solid_wood, + "factory_profit_rate": profit_rate, + "factory_profit_amount": profit_amount, + "before_output_tax": before_output_tax, + "factory_output_tax_rate": cls.FACTORY_OUTPUT_TAX_RATE, + "factory_output_tax_amount": output_tax_amount, + "bom_factory_quote": bom_factory_quote, + "formula": "(BOM制造成本+工厂利润)×(1+生产出厂税率)", + } + + def _post_quote_answer(self, project: dict, *, validation_error: str = "") -> str: + selections = dict(project.get("selections") or {}) + result = dict(project.get("result") or {}) + quote = dict(result.get("quote") or {}) + summary = dict(quote.get("summary") or {}) + base_cost = float(summary.get("total_mid") or 0) + factory = self._factory_quote_layer(base_cost, selections) + step = str(selections.get("post_quote_step") or "wholesale_need") + warning = f"> **请先修正:** {validation_error}\n\n" if validation_error else "" + if step == "completed": + # A finalized quote must never fall through to the profit-margin + # prompt. This also makes older stale checkpoints safe to resume. + return self._completed_answer(project) + if step == "wholesale_need": + return ( + "### BOM出厂报价已完成\n\n" + f"> BOM制造成本:**¥{base_cost:,.2f}**;工厂利润" + f"({'纯实木' if factory['pure_solid_wood'] else '非纯实木'} {factory['factory_profit_rate']:.0%})" + f"和生产出厂税7%已计入,单件BOM出厂报价:**¥{factory['bom_factory_quote']:,.2f}**。" + "CAD、BOM和包装模拟已保存,下一步决定是否换算1688批发价。\n\n" + + self._quick_replies( + "是否需报1688批发价格", + [ + {"label": "需要1688批发价", "message": "【技能路由:ai-solid-wood-cost】是,需要报1688批发价格", "intent": "confirm_1688_wholesale"}, + {"label": "只要BOM出厂报价", "message": "【技能路由:ai-solid-wood-cost】否,只要BOM出厂报价", "intent": "skip_1688_wholesale"}, + ], + ) + ) + if step == "special_need": + return ( + "### 1688批发价格设置\n\n" + f"> BOM出厂报价:**¥{factory['bom_factory_quote']:,.2f}/件**(已经包含工厂利润和7%出厂税)。" + "特殊操作只计算买家明确要求的额外服务,不自动重复加价。\n\n" + + self._quick_replies( + "是否有特殊操作需求", + [ + {"label": "有特殊操作", "message": "【技能路由:ai-solid-wood-cost】有特殊操作需求", "intent": "confirm_special_operations"}, + {"label": "无特殊操作", "message": "【技能路由:ai-solid-wood-cost】无特殊操作需求", "intent": "skip_special_operations"}, + ], + ) + ) + version, prices = self._active_special_operation_prices() + selected_codes = [ + code for code in selections.get("special_operation_codes") or [] if code in prices + ] + if step == "special_select": + selected_text = "、".join(prices[code]["operation_name"] for code in selected_codes) or "尚未选择" + actions = [ + { + "label": str(row["operation_name"]), + "message": f"【技能路由:ai-solid-wood-cost】添加特殊操作:{row['operation_name']}", + "intent": "add_special_operation", + } + for code, row in prices.items() if code not in selected_codes + ] + actions.append({"label": "选择完成", "message": "【技能路由:ai-solid-wood-cost】特殊操作选择完成", "intent": "finish_special_operation_selection"}) + return ( + "### 选择1688特殊操作\n\n" + + warning + + f">已选:**{selected_text}**。可连续点选多项,系统会去重;选完后点“选择完成”。\n\n" + + self._quick_replies("选择额外服务", actions) + ) + if step == "special_material": + basis = dict(selections.get("special_operation_material_basis") or {}) + pending = next((code for code in selected_codes if code not in basis), "") + if not pending: + pending = selected_codes[0] if selected_codes else "" + name = str(prices.get(pending, {}).get("operation_name") or "特殊操作") + return ( + f"### 确认耗材口径:{name}\n\n" + + warning + + "> 每个特殊操作的“含耗材/不含耗材”单独保存,两种单价只选一个,不会叠加。\n\n" + + self._quick_replies( + "选择计价口径", + [ + {"label": f"{name}·含耗材", "message": f"【技能路由:ai-solid-wood-cost】{name}按含耗材计价", "intent": "special_operation_including_material"}, + {"label": f"{name}·不含耗材", "message": f"【技能路由:ai-solid-wood-cost】{name}按不含耗材计价", "intent": "special_operation_excluding_material"}, + ], + ) + ) + if step == "invoice_tax": + return ( + "### 确认1688附加税点\n\n" + + self._quick_replies( + "1688批发报价是否再增加5%税点", + [ + {"label": "增加5%税点", "message": "【技能路由:ai-solid-wood-cost】1688批发报价增加5%税点", "intent": "confirm_invoice_tax"}, + {"label": "不增加", "message": "【技能路由:ai-solid-wood-cost】1688批发报价不增加5%税点", "intent": "skip_invoice_tax"}, + ], + ) + ) + return ( + "### 输入目标利润空间\n\n" + + warning + + "> 请输入 **20%~25%**。最终1688批发价 = (BOM出厂报价 + 特殊操作 + 1688附加税额) ÷(1 - 利润空间)。\n\n" + + self._quick_replies( + "选择利润空间", + [ + {"label": "20%", "message": "【技能路由:ai-solid-wood-cost】目标利润空间20%", "intent": "confirm_profit_margin"}, + {"label": "22%", "message": "【技能路由:ai-solid-wood-cost】目标利润空间22%", "intent": "confirm_profit_margin"}, + {"label": "25%", "message": "【技能路由:ai-solid-wood-cost】目标利润空间25%", "intent": "confirm_profit_margin"}, + ], + ) + ) + + def _special_operation_lines(self, project: dict, selections: dict) -> tuple[dict, list[dict]]: + version, prices = self._active_special_operation_prices() + result = dict(project.get("result") or {}) + summary = dict((result.get("quote") or {}).get("summary") or {}) + package_volume = max(0.0, float(summary.get("total_packaging_volume_m3") or 0)) + basis = dict(selections.get("special_operation_material_basis") or {}) + lines: list[dict] = [] + seen: set[str] = set() + for code in selections.get("special_operation_codes") or []: + code = str(code) + if code in seen or code not in prices: + continue + seen.add(code) + row = prices[code] + material_basis = str(basis.get(code) or "excluding_material") + if material_basis not in {"including_material", "excluding_material"}: + raise SolidWoodCostError(f"特殊操作耗材口径未确认:{row['operation_name']}") + driver_unit = str(row["driver_unit"]) + driver_quantity = package_volume if driver_unit == "m3" else 1.0 + unit_price = float( + row["price_including_material"] + if material_basis == "including_material" + else row["price_excluding_material"] + ) + lines.append( + { + "operation_code": code, + "operation_name": str(row["operation_name"]), + "material_basis": material_basis, + "material_basis_label": "含耗材" if material_basis == "including_material" else "不含耗材", + "driver_quantity": round(driver_quantity, 6), + "driver_unit": driver_unit, + "unit_price": round(unit_price, 4), + "amount": round(driver_quantity * unit_price, 2), + } + ) + return version, lines + + def _archive_final_quote( + self, + project: dict, + *, + selections: dict, + quote: dict, + conversation_id: str, + user_id: str, + ) -> str: + project_id = str(project["project_id"]) + archive_id = f"archive_{project_id}" + factory = dict(quote.get("factory_quote") or {}) + wholesale = dict(quote.get("wholesale_quote") or {}) + now = self._now() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO cost_quote_archives( + archive_id,project_id,conversation_id,requested_by_user_id,operator_id, + operator_context_json,product_name,cost_model_version,base_cost, + factory_profit_rate,factory_profit_amount,factory_output_tax_rate, + factory_output_tax_amount,bom_factory_quote, + special_operation_total,invoice_tax_rate,invoice_tax_amount,profit_margin, + final_quote,selections_json,quote_json,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(project_id) DO UPDATE SET + conversation_id=excluded.conversation_id, + requested_by_user_id=excluded.requested_by_user_id, + operator_id=excluded.operator_id, + operator_context_json=excluded.operator_context_json, + product_name=excluded.product_name, + cost_model_version=excluded.cost_model_version, + base_cost=excluded.base_cost, + factory_profit_rate=excluded.factory_profit_rate, + factory_profit_amount=excluded.factory_profit_amount, + factory_output_tax_rate=excluded.factory_output_tax_rate, + factory_output_tax_amount=excluded.factory_output_tax_amount, + bom_factory_quote=excluded.bom_factory_quote, + special_operation_total=excluded.special_operation_total, + invoice_tax_rate=excluded.invoice_tax_rate, + invoice_tax_amount=excluded.invoice_tax_amount, + profit_margin=excluded.profit_margin, + final_quote=excluded.final_quote, + selections_json=excluded.selections_json, + quote_json=excluded.quote_json, + updated_at=excluded.updated_at + """, + ( + archive_id, + project_id, + str(conversation_id or ""), + str(user_id or ""), + str(user_id or ""), + json.dumps({"source": "chat_api", "user_id": str(user_id or "")}, ensure_ascii=False), + str((project.get("analysis") or {}).get("product_name") or project.get("product_name") or ""), + self.COST_MODEL_VERSION, + float(factory.get("manufacturing_cost") or wholesale.get("base_cost") or 0), + float(factory.get("factory_profit_rate") or 0), + float(factory.get("factory_profit_amount") or 0), + float(factory.get("factory_output_tax_rate") or 0), + float(factory.get("factory_output_tax_amount") or 0), + float(factory.get("bom_factory_quote") or wholesale.get("bom_factory_quote") or 0), + float(wholesale.get("special_operation_total") or 0), + float(wholesale.get("invoice_tax_rate") or 0), + float(wholesale.get("invoice_tax_amount") or 0), + float(wholesale.get("profit_margin") or 0), + float(wholesale.get("final_quote") or 0), + json.dumps(selections, ensure_ascii=False), + json.dumps(quote, ensure_ascii=False), + now, + now, + ), + ) + conn.execute("DELETE FROM cost_quote_archive_assets WHERE archive_id=?", (archive_id,)) + asset_rows = conn.execute( + "SELECT * FROM project_assets WHERE project_id=? AND replaced_by_asset_id IS NULL ORDER BY asset_id", + (project_id,), + ).fetchall() + conn.executemany( + """ + INSERT INTO cost_quote_archive_assets( + archive_id,source_asset_id,role,filename,kind,value,sha256, + validation_status,validation_json + ) VALUES(?,?,?,?,?,?,?,?,?) + """, + [ + ( + archive_id, int(row["asset_id"]), str(row["role"]), str(row["filename"]), + str(row["kind"]), str(row["value"]), str(row["sha256"]), + str(row["validation_status"]), str(row["validation_json"]), + ) + for row in asset_rows + ], + ) + conn.execute("DELETE FROM cost_quote_archive_bom_lines WHERE archive_id=?", (archive_id,)) + bom_lines = [line for line in quote.get("material_lines") or [] if isinstance(line, dict)] + conn.executemany( + """ + INSERT INTO cost_quote_archive_bom_lines( + archive_id,part_id,part_name,quantity,dimensions_json,material_name,cost_mid,line_json + ) VALUES(?,?,?,?,?,?,?,?) + """, + [ + ( + archive_id, str(line.get("part_id") or ""), str(line.get("name") or ""), + float(line.get("quantity") or 0), json.dumps(line.get("dimensions_mm") or {}, ensure_ascii=False), + str(line.get("species") or ""), float(line.get("cost_mid") or 0), + json.dumps(line, ensure_ascii=False), + ) + for line in bom_lines + ], + ) + conn.execute("DELETE FROM cost_quote_archive_special_operations WHERE archive_id=?", (archive_id,)) + operation_lines = [line for line in wholesale.get("special_operations") or [] if isinstance(line, dict)] + conn.executemany( + """ + INSERT INTO cost_quote_archive_special_operations( + archive_id,operation_code,operation_name,material_basis,driver_quantity, + driver_unit,unit_price,amount + ) VALUES(?,?,?,?,?,?,?,?) + """, + [ + ( + archive_id, str(line.get("operation_code") or ""), str(line.get("operation_name") or ""), + str(line.get("material_basis") or ""), float(line.get("driver_quantity") or 0), + str(line.get("driver_unit") or ""), float(line.get("unit_price") or 0), + float(line.get("amount") or 0), + ) + for line in operation_lines + ], + ) + return archive_id + + def _finalize_post_quote( + self, + project: dict, + *, + selections: dict, + conversation_id: str, + user_id: str, + ) -> dict: + result = dict(project.get("result") or {}) + quote = dict(result.get("quote") or {}) + summary = dict(quote.get("summary") or {}) + base_cost = round(float(summary.get("total_mid") or 0), 2) + factory_quote = self._factory_quote_layer(base_cost, selections) + bom_factory_quote = float(factory_quote["bom_factory_quote"]) + wholesale_enabled = bool(selections.get("wholesale_1688_enabled")) + special_version, operation_lines = self._special_operation_lines(project, selections) + if not wholesale_enabled: + operation_lines = [] + special_total = round(sum(float(line.get("amount") or 0) for line in operation_lines), 2) + tax_rate = self.WHOLESALE_1688_TAX_RATE if wholesale_enabled and selections.get("invoice_tax_enabled") else 0.0 + taxable_cost = round(bom_factory_quote + special_total, 2) + tax_amount = round(taxable_cost * tax_rate, 2) + profit_margin = float(selections.get("profit_margin") or 0) if wholesale_enabled else 0.0 + if wholesale_enabled and not 0.20 <= profit_margin <= 0.25: + raise SolidWoodCostError("利润空间必须在20%至25%之间") + final_quote = round((taxable_cost + tax_amount) / (1.0 - profit_margin), 2) if wholesale_enabled else bom_factory_quote + wholesale_quote = { + "enabled": wholesale_enabled, + "base_cost": base_cost, + "manufacturing_cost": base_cost, + "bom_factory_quote": bom_factory_quote, + "wholesale_base_cost": bom_factory_quote, + "special_operation_price_version": special_version.get("version_label") if wholesale_enabled else "", + "special_operations": operation_lines, + "special_operation_total": special_total, + "taxable_cost": taxable_cost, + "invoice_tax_rate": tax_rate, + "invoice_tax_amount": tax_amount, + "wholesale_tax_rate": tax_rate, + "wholesale_tax_amount": tax_amount, + "profit_margin": profit_margin, + "final_quote": final_quote, + "formula": "(BOM出厂报价+特殊操作+1688附加税额)/(1-利润空间)" if wholesale_enabled else "BOM出厂报价", + } + quote["factory_quote"] = factory_quote + quote["wholesale_quote"] = wholesale_quote + result["quote"] = quote + job_dir = self.state_dir / "jobs" / str(project["project_id"]) + if job_dir.is_dir(): + result.update(self._write_quote_files(job_dir, str(project["project_id"]), quote)) + selections = dict(selections) + selections["post_quote_step"] = "completed" + archive_id = self._archive_final_quote( + project, + selections=selections, + quote=quote, + conversation_id=conversation_id, + user_id=user_id, + ) + result["archive"] = {"archive_id": archive_id, "archived_at": self._now()} + with self._connect() as conn: + latest = conn.execute( + "SELECT quote_id FROM quote_versions WHERE project_id=? ORDER BY created_at DESC LIMIT 1", + (project["project_id"],), + ).fetchone() + if latest: + conn.execute( + "UPDATE quote_versions SET quote_json=? WHERE quote_id=?", + (json.dumps(quote, ensure_ascii=False), str(latest["quote_id"])), + ) + completed = self._update_project( + project["project_id"], + "completed", + "BOM出厂报价、1688批发价和报价档案已完成", + selections=selections, + result=result, + ) + return { + "status": "completed", + "answer": self._completed_answer(completed), + "files": [], + "local_files": result, + "project_id": project["project_id"], + "archive_id": archive_id, + } + + def _handle_post_quote( + self, + project: dict, + message: str, + *, + conversation_id: str, + user_id: str, + ) -> dict: + text = str(message or "").strip() + selections = dict(project.get("selections") or {}) + step = str(selections.get("post_quote_step") or "wholesale_need") + if step == "completed": + return { + "status": "completed", + "answer": self._completed_answer(project), + "files": (project.get("result") or {}).get("files") or [], + "project_id": project["project_id"], + } + if step == "wholesale_need": + if "只要BOM" in text.upper() or re.search(r"(?:不需要|无需|否).{0,8}(?:1688|批发)?", text): + selections.update({"wholesale_1688_enabled": False, "special_operation_codes": [], "invoice_tax_enabled": False, "profit_margin": 0.0}) + return self._finalize_post_quote(project, selections=selections, conversation_id=conversation_id, user_id=user_id) + if "1688" in text and re.search(r"是|需要|报", text): + selections.update({"wholesale_1688_enabled": True, "post_quote_step": "special_need"}) + else: + return {"status": "waiting", "answer": self._post_quote_answer(project, validation_error="请选择是否需要1688批发价"), "files": []} + elif step == "special_need": + if re.search(r"(?:无|没有|不需要).{0,6}特殊操作", text): + selections.update({"special_operation_codes": [], "special_operation_material_basis": {}, "post_quote_step": "invoice_tax"}) + elif "特殊操作" in text and re.search(r"有|需要", text): + selections.update({"special_operation_codes": [], "special_operation_material_basis": {}, "post_quote_step": "special_select"}) + else: + return {"status": "waiting", "answer": self._post_quote_answer(project, validation_error="请选择有或无特殊操作"), "files": []} + elif step == "special_select": + _version, prices = self._active_special_operation_prices() + selected = [code for code in selections.get("special_operation_codes") or [] if code in prices] + for code, row in prices.items(): + if str(row["operation_name"]) in text and code not in selected: + selected.append(code) + selections["special_operation_codes"] = selected + if "选择完成" in text or "选完" in text: + if not selected: + updated = self._update_project(project["project_id"], "waiting_confirmation", "等待选择1688特殊操作", selections=selections) + return {"status": "waiting", "answer": self._post_quote_answer(updated, validation_error="已选择“有特殊操作”,请至少选一项"), "files": []} + selections["post_quote_step"] = "special_material" + elif not any(str(row["operation_name"]) in text for row in prices.values()): + return {"status": "waiting", "answer": self._post_quote_answer(project, validation_error="请点选一项额外服务,或点击“选择完成”"), "files": []} + elif step == "special_material": + _version, prices = self._active_special_operation_prices() + selected = [code for code in selections.get("special_operation_codes") or [] if code in prices] + basis = dict(selections.get("special_operation_material_basis") or {}) + pending = next((code for code in selected if code not in basis), "") + if not pending: + selections["post_quote_step"] = "invoice_tax" + elif "不含耗材" in text: + basis[pending] = "excluding_material" + elif "含耗材" in text: + basis[pending] = "including_material" + else: + return {"status": "waiting", "answer": self._post_quote_answer(project, validation_error="请选择含耗材或不含耗材"), "files": []} + selections["special_operation_material_basis"] = basis + if selected and all(code in basis for code in selected): + selections["post_quote_step"] = "invoice_tax" + elif step == "invoice_tax": + if re.search(r"(?:不增加|不开票|无需).{0,8}(?:税|开票)?", text): + selections.update({"invoice_tax_enabled": False, "post_quote_step": "profit_margin"}) + elif "5%" in text or "5%" in text or ("增加" in text and "税" in text): + selections.update({"invoice_tax_enabled": True, "post_quote_step": "profit_margin"}) + else: + return {"status": "waiting", "answer": self._post_quote_answer(project, validation_error="请选择1688批发报价是否再增加5%税点"), "files": []} + elif step == "profit_margin": + match = re.search(r"(? str: + return ( + f"### {self.DISPLAY_NAME}\n\n" + "> **当前步骤:** 选择本次评估用途。用途只影响结果的校验严格度,不会重复计费。\n\n" + + self._quick_replies( + "选择成本用途", + [ + {"label": "快速概算", "message": "【技能路由:ai-solid-wood-cost】本次用于概算", "intent": "confirm_cost_purpose"}, + {"label": "工程报价", "message": "【技能路由:ai-solid-wood-cost】本次用于工程报价", "intent": "confirm_cost_purpose"}, + {"label": "实际成本复核", "message": "【技能路由:ai-solid-wood-cost】本次用于实际成本复核", "intent": "confirm_cost_purpose"}, + ], + ) + ) + + def _panel_thickness_answer(self, project: dict) -> str: + analysis = project.get("analysis") or {} + selections = project.get("selections") or {} + thicknesses = sorted( + { + round(min(float(value) for value in (item.get("dimensions_mm") or {}).values()), 1) + for item in analysis.get("components") or [] + if len((item.get("dimensions_mm") or {})) == 3 + } + ) + thickness_text = "、".join(f"{value:g}mm" for value in thicknesses) or "未识别" + panel_code = str(selections.get("panel_code") or "") + twelve_mm_reference = "" + if panel_code in {"MDF", "PARTICLEBOARD"}: + price = self._panel_price_for(panel_code, 12.0, "E1") + twelve_mm_reference = ( + f"- `统一12mm(1.2cm)`:当前E1基准价约¥{float(price['price_mid_per_sheet']):.2f}/张" + f"(¥{float(price['price_low_per_sheet']):.2f}–{float(price['price_high_per_sheet']):.2f}/张)," + "后续选择E0或ENF时自动应用环保等级系数。\n" + ) + return ( + "### 确认板材厚度\n\n" + f"> **图片识别到的板厚:** {thickness_text} \n" + "> **当前步骤:** 选择板厚计算方式。选择“统一”时会应用到所有板件,包括背板和抽屉底板。\n\n" + "- `按尺寸图自动识别`:系统按每个部件识别到的厚度自动选择最接近的标准板。\n" + "- `主体15mm+薄板2.5mm`:主体板统一15mm,背板和抽屉底板使用独立2.5mm薄型背板价。\n" + + twelve_mm_reference + + "- `统一12/15/18mm`:字面意义是所有板件都使用该厚度;需要薄背板时请选择上一项分组方案。\n\n" + + self._quick_replies( + "选择板厚口径", + [ + {"label": "按尺寸图自动识别", "message": "【技能路由:ai-solid-wood-cost】按尺寸图自动识别板厚", "intent": "confirm_panel_thickness"}, + {"label": "主体15mm+薄板2.5mm", "message": "【技能路由:ai-solid-wood-cost】主体15mm,背板和抽屉底板2.5mm", "intent": "confirm_panel_thickness"}, + {"label": "统一12mm(1.2cm)", "message": "【技能路由:ai-solid-wood-cost】统一板厚12mm", "intent": "confirm_panel_thickness"}, + {"label": "统一18mm", "message": "【技能路由:ai-solid-wood-cost】统一板厚18mm", "intent": "confirm_panel_thickness"}, + {"label": "统一15mm", "message": "【技能路由:ai-solid-wood-cost】统一板厚15mm", "intent": "confirm_panel_thickness"}, + ], + ) + ) + + def _emission_grade_answer(self, project: dict) -> str: + panel_code = str((project.get("selections") or {}).get("panel_code") or "") + actions = [ + {"label": "E1", "message": "【技能路由:ai-solid-wood-cost】环保等级E1", "intent": "confirm_emission"}, + {"label": "E0", "message": "【技能路由:ai-solid-wood-cost】环保等级E0", "intent": "confirm_emission"}, + {"label": "ENF", "message": "【技能路由:ai-solid-wood-cost】环保等级ENF", "intent": "confirm_emission"}, + ] + if panel_code in {"MDF", "MDF_UNGRADED"}: + actions.append( + { + "label": "无等级(公司普通板)", + "message": "【技能路由:ai-solid-wood-cost】选择无环保等级密度板", + "intent": "confirm_emission", + } + ) + return ( + "### 确认环保等级\n\n> **当前步骤:** 本步只选择板材环保等级。密度板可选“无等级”,使用公司普通双贴板BOM反推价。\n\n" + + self._quick_replies( + "选择环保等级", + actions, + ) + ) + + def _panel_purchase_answer(self, project: dict) -> str: + return ( + "### 确认板材采购状态\n\n" + "> **当前步骤:** 区分采购的是裸板还是成品饰面板,避免贴面材料和人工重复计费。\n\n" + + self._quick_replies( + "选择采购状态", + [ + {"label": "裸板", "message": "【技能路由:ai-solid-wood-cost】采购裸板,贴面在厂内完成", "intent": "confirm_panel_purchase"}, + {"label": "成品双饰面板", "message": "【技能路由:ai-solid-wood-cost】采购成品双饰面板", "intent": "confirm_panel_purchase"}, + {"label": "已贴木皮板", "message": "【技能路由:ai-solid-wood-cost】采购已贴木皮板", "intent": "confirm_panel_purchase"}, + ], + ) + ) + + def _edge_material_answer(self, project: dict) -> str: + return ( + "### 选择封边材质\n\n> **当前步骤:** 本步只选择封边条材质。\n\n" + + self._quick_replies( + "选择封边材质", + [ + {"label": "PVC", "message": "【技能路由:ai-solid-wood-cost】使用PVC封边", "intent": "confirm_edge_material"}, + {"label": "ABS", "message": "【技能路由:ai-solid-wood-cost】使用ABS封边", "intent": "confirm_edge_material"}, + {"label": "实木封边", "message": "【技能路由:ai-solid-wood-cost】使用实木封边", "intent": "confirm_edge_material"}, + ], + ) + ) + + def _edge_thickness_answer(self, project: dict) -> str: + return ( + "### 选择封边厚度\n\n> **当前步骤:** 本步只选择封边条厚度。\n\n" + + self._quick_replies( + "选择封边厚度", + [ + {"label": "0.5mm", "message": "【技能路由:ai-solid-wood-cost】封边厚度0.5mm", "intent": "confirm_edge_thickness"}, + {"label": "1mm", "message": "【技能路由:ai-solid-wood-cost】封边厚度1mm", "intent": "confirm_edge_thickness"}, + {"label": "2mm", "message": "【技能路由:ai-solid-wood-cost】封边厚度2mm", "intent": "confirm_edge_thickness"}, + ], + ) + ) + + def _edge_scope_answer(self, project: dict) -> str: + return ( + "### 选择封边范围\n\n" + "> **当前步骤:** 封边长度将按本次选择进入材料和人工公式。自定义时可直接回复“自定义封边12米”。\n\n" + + self._quick_replies( + "选择封边范围", + [ + {"label": "仅外露边", "message": "【技能路由:ai-solid-wood-cost】仅外露边封边", "intent": "confirm_edge_scope"}, + {"label": "全部边", "message": "【技能路由:ai-solid-wood-cost】全部板件四边封边", "intent": "confirm_edge_scope"}, + {"label": "自定义", "message": "【技能路由:ai-solid-wood-cost】自定义封边,请输入封边总长度(米)", "intent": "confirm_edge_scope"}, + ], + ) + ) + + def _finish_answer(self, project: dict) -> str: + selections = project.get("selections") or {} + panel_code = str(selections.get("panel_code") or "") + purchase_state = str(selections.get("panel_purchase_state") or "") + surface_label = "饰面/油漆" if panel_code else "油漆" + gate_text = "未选择前不会进入生产包装" if panel_code else "未选油漆前不会进入生产包装" + if purchase_state == "prefinished": + finish_actions = ["三聚氰胺双饰面"] + elif purchase_state == "preveneered": + finish_actions = ["PU清漆", "水性漆", "木蜡油"] + elif panel_code == "MDF": + finish_actions = ["水性漆", "封闭漆", "木皮+PU清漆", "三聚氰胺双饰面"] + elif panel_code == "PLYWOOD": + finish_actions = ["PU清漆", "水性漆", "木皮+PU清漆", "三聚氰胺双饰面"] + elif panel_code == "PARTICLEBOARD": + finish_actions = ["三聚氰胺双饰面", "木皮+PU清漆", "HPL耐磨饰面"] + else: + finish_actions = ["PU清漆", "木蜡油", "开放漆", "封闭漆"] + action_rows = [ + {"label": finish, "message": f"【技能路由:ai-solid-wood-cost】使用{finish},下一步", "intent": "confirm_finish"} + for finish in finish_actions + ] + return ( + "### 材质已保存\n\n" + f"> **当前步骤:** 选择{surface_label}工艺。本步不再修改已保存的材质,{gate_text}。\n\n" + f"- 主材:{selections.get('main_species', '待确认')}\n" + f"- 辅材:{selections.get('aux_species', selections.get('main_species', '待确认'))}\n\n" + "请从下方选择适合当前基材的饰面/油漆;系统会分别计算贴面或涂料材料、表面加工人工,不会把免漆板误算成多遍油漆。\n\n" + "```openclaw-actions\n" + + json.dumps( + { + "openclaw_actions": True, + "type": "quick_replies", + "title": "选择饰面与油漆工艺", + "actions": action_rows, + }, + ensure_ascii=False, + indent=2, + ) + + "\n```" + ) + + def _finish_profile(self, selections: dict) -> dict: + finish_name = str(selections.get("finish") or "无木器饰面") + profile = dict(self.FINISH_TEMPLATES[finish_name]) + profile.setdefault("coating_labor_h_m2", 0.0) + profile.setdefault("intercoat_sanding_h_m2", 0.0) + profile.setdefault("surface_labor_h_m2", 0.0) + profile.setdefault("surface_material_m2", 0.0) + purchase_state = str(selections.get("panel_purchase_state") or "") + if self._is_panel_selection(selections) and purchase_state == "prefinished": + profile.update( + { + "coats": 0, + "price_per_l": 0.0, + "coating_labor_h_m2": 0.0, + "intercoat_sanding_h_m2": 0.0, + "surface_labor_h_m2": 0.0, + "surface_material_m2": 0.0, + "prefinished_panel": True, + } + ) + elif self._is_panel_selection(selections) and purchase_state == "preveneered": + profile["surface_labor_h_m2"] = 0.0 + profile["surface_material_m2"] = 0.0 + profile["prefinished_panel"] = False + elif self._is_panel_selection(selections) and purchase_state == "bare" and finish_name == "三聚氰胺双饰面": + profile.update( + { + "prefinished_panel": False, + "panel_premium_m2": 0.0, + "surface_material_m2": 0.60, + "surface_labor_h_m2": 0.16, + } + ) + return profile + + def _finish_details_answer(self, project: dict) -> str: + selections = project.get("selections") or {} + return ( + "### 确认油漆外观\n\n" + f"> **已选油漆体系:** {selections.get('finish', '待确认')} \n" + "> **当前步骤:** 只确认颜色、木纹开放/封闭和光泽;这些参数用于工艺路线与风险说明。\n\n" + + self._quick_replies( + "选择油漆外观", + [ + {"label": "原木色·开放·哑光", "message": "【技能路由:ai-solid-wood-cost】透明原木色,开放效果,哑光", "intent": "confirm_finish_details"}, + {"label": "胡桃色·开放·哑光", "message": "【技能路由:ai-solid-wood-cost】胡桃色,开放效果,哑光", "intent": "confirm_finish_details"}, + {"label": "白色·封闭·半哑", "message": "【技能路由:ai-solid-wood-cost】白色,封闭效果,半哑光", "intent": "confirm_finish_details"}, + {"label": "原木色·封闭·亮光", "message": "【技能路由:ai-solid-wood-cost】透明原木色,封闭效果,亮光", "intent": "confirm_finish_details"}, + ], + ) + ) + + def _process_answer(self, project: dict) -> str: + evidence = (project.get("analysis") or {}).get("process_evidence") or {} + totals = evidence.get("totals") or {} + if evidence.get("requires_confirmation"): + return ( + "### 确认异形与雕刻工艺\n\n" + "> 图片中可能存在加工造型,但识别置信度不足。未确认前不会计入雕刻或异形加工费。\n\n" + f"当前识别:孔{int(totals.get('holes') or 0)}个、槽{int(totals.get('slots') or 0)}个、" + f"异形线{float(totals.get('edge_profile_m') or 0):.2f}米、雕刻面积{float(totals.get('carving_area_m2') or 0):.3f}平方米。\n\n" + + self._quick_replies( + "确认是否存在雕刻造型", + [ + {"label": "确认无雕刻", "message": "【技能路由:ai-solid-wood-cost】确认无雕刻,仅保留尺寸图明确的孔槽", "intent": "confirm_process_features"}, + {"label": "确认有雕刻", "message": "【技能路由:ai-solid-wood-cost】确认有雕刻,按图片识别面积和路径估算", "intent": "confirm_process_features"}, + ], + ) + ) + return "" + + def _batch_answer(self, project: dict) -> str: + return ( + "### 选择包装方式\n\n" + f"> **成本口径:** 默认按批量生产({self.DEFAULT_BATCH_QTY}件/批)摊销换线与准备工时,不需要用户选择生产数量。\n\n" + + self._quick_replies( + "选择包装方式", + [ + {"label": "拆装", "message": "【技能路由:ai-solid-wood-cost】拆装包装", "intent": "confirm_packaging_mode"}, + {"label": "整装", "message": "【技能路由:ai-solid-wood-cost】整装包装", "intent": "confirm_packaging_mode"}, + ], + ) + ) + + def _packaging_mode_answer(self, project: dict) -> str: + return ( + "### 选择包装方式\n\n" + f"> **成本口径:** 默认按批量生产({self.DEFAULT_BATCH_QTY}件/批)摊销准备工时,不需要选择生产数量。 \n" + "> **当前步骤:** 只需选择拆装或整装。系统会根据CAD部件叠放或成品外包围自动计算包装尺寸、体积和包材BOM。\n\n" + + self._quick_replies( + "选择包装方式", + [ + {"label": "拆装", "message": "【技能路由:ai-solid-wood-cost】拆装包装", "intent": "confirm_packaging_mode"}, + {"label": "整装", "message": "【技能路由:ai-solid-wood-cost】整装包装", "intent": "confirm_packaging_mode"}, + ], + ) + ) + + def _packaging_box_style_answer(self, project: dict) -> str: + selections = project.get("selections") or {} + recommended_code = self._recommended_packaging_box_style(project.get("analysis") or {}, selections) + recommended = self.PACKAGING_BOX_STYLES[recommended_code] + return ( + "### 选择包装箱型\n\n" + f"> **系统推荐:** {recommended['label']}({recommended['fefco_family']})。{recommended['description']} \n" + "> **当前步骤:** 箱型会进入纸板展开面积、包装人工、包装CAD和最终报价;不是只改显示名称。\n\n" + + self._quick_replies( + "选择包装箱型", + [ + { + "label": f"系统推荐({recommended['label']})", + "message": f"【技能路由:ai-solid-wood-cost】采用系统推荐箱型:{recommended['label']}", + "intent": "confirm_packaging_box_style", + }, + {"label": "中开箱", "message": "【技能路由:ai-solid-wood-cost】使用中开箱包装", "intent": "confirm_packaging_box_style"}, + {"label": "天地盖", "message": "【技能路由:ai-solid-wood-cost】使用天地盖包装", "intent": "confirm_packaging_box_style"}, + {"label": "围板天地盖", "message": "【技能路由:ai-solid-wood-cost】使用围板天地盖包装", "intent": "confirm_packaging_box_style"}, + ], + ) + ) + + def _carton_grade_answer(self, project: dict) -> str: + selections = project.get("selections") or {} + recommended_code = self._recommended_carton_grade(project.get("analysis") or {}, selections) + recommended = self.CARTON_GRADES[recommended_code] + return ( + "### 选择纸箱成本等级\n\n" + f"> **系统推荐:** {recommended['label']}。{recommended['description']} \n" + "> **公司基准:** 90cm推拉餐柜BOM纸箱为4.088㎡×¥4.00/㎡=¥16.352,因此经济级按¥4.00/㎡。 \n" + "> **公司低价档:** 历史BOM另有¥3.00/㎡记录,作为轻小件、拆装、国内简装使用的简装级。 \n" + "> **价差标准:** 简装级¥3.00/㎡、经济级¥4.00/㎡、标准级¥6.00/㎡、加强级¥8.00/㎡。 \n" + "> **性能规格提醒:** 历史BOM没有层数、楞型、克重、耐破和边压值;这里是成本分级,量产前需用纸箱厂打样规格确认性能。\n\n" + + self._quick_replies( + "选择纸箱成本等级", + [ + { + "label": f"系统推荐({recommended['label']})", + "message": f"【技能路由:ai-solid-wood-cost】采用系统推荐纸箱等级:{recommended['label']}", + "intent": "confirm_carton_grade", + }, + {"label": "简装级 ¥3.00/㎡", "message": "【技能路由:ai-solid-wood-cost】使用简装级纸箱", "intent": "confirm_carton_grade"}, + {"label": "经济级 ¥4.00/㎡", "message": "【技能路由:ai-solid-wood-cost】使用经济级纸箱", "intent": "confirm_carton_grade"}, + {"label": "标准级 ¥6.00/㎡", "message": "【技能路由:ai-solid-wood-cost】使用标准级纸箱", "intent": "confirm_carton_grade"}, + {"label": "加强级 ¥8.00/㎡", "message": "【技能路由:ai-solid-wood-cost】使用加强级纸箱", "intent": "confirm_carton_grade"}, + ], + ) + ) + + def _confirmation_edit_actions(self, project: dict, group: str = "") -> list[dict]: + """Return one edit action for every user-confirmed configuration step.""" + selections = project.get("selections") or {} + analysis = project.get("analysis") or {} + + def action(label: str, target: str, action_group: str) -> dict: + return { + "label": label, + "message": f"【技能路由:ai-solid-wood-cost】修改步骤:{target}", + "intent": "change_quote_condition", + "_group": action_group, + } + + actions = [ + action("修改评估用途", "评估用途", "basic"), + action("修改用材结构", "用材结构", "basic"), + ] + if not self._is_all_metal_selection(selections): + actions.append(action("修改木材/板材", "木质材料", "basic")) + if self._is_panel_selection(selections): + actions.extend( + [ + action("修改板厚", "板材厚度", "panel"), + action("修改环保等级", "环保等级", "panel"), + action("修改采购状态", "板材采购状态", "panel"), + action("修改封边材质", "封边材质", "panel"), + action("修改封边厚度", "封边厚度", "panel"), + action("修改封边范围", "封边范围", "panel"), + ] + ) + has_door_or_drawer = any( + ("门" in str(item.get("name") or "")) + or ("抽屉" in str(item.get("name") or "") and "底" not in str(item.get("name") or "")) + for item in analysis.get("components") or [] + ) + if has_door_or_drawer: + actions.append(action("修改五金档次", "五金档次", "surface")) + if not self._is_all_metal_selection(selections): + actions.append(action("修改饰面/油漆", "饰面油漆", "surface")) + if int(self._finish_profile(selections).get("coats") or 0) > 0: + actions.append(action("修改油漆外观", "油漆外观", "surface")) + if self._is_metal_selection(selections): + actions.extend( + [ + action("修改金属部件", "金属部件范围", "metal"), + action("修改金属材质", "金属材质", "metal"), + action("修改金属型材", "金属型材", "metal"), + action("修改金属壁厚", "金属壁厚", "metal"), + action("修改连接/焊接", "金属连接", "metal"), + action("修改金属表面", "金属表面", "metal"), + ] + ) + if ((analysis.get("process_evidence") or {}).get("requires_confirmation")): + actions.append(action("修改雕刻/异形", "雕刻异形工艺", "surface")) + actions.extend( + [ + action("修改包装方式", "包装方式", "production"), + action("修改包装箱型", "包装箱型", "production"), + action("修改纸箱等级", "纸箱等级", "production"), + ] + ) + if group: + actions = [item for item in actions if item.get("_group") == group] + return [{key: value for key, value in item.items() if key != "_group"} for item in actions] + + def _confirmation_edit_menu_actions(self, project: dict) -> list[dict]: + selections = project.get("selections") or {} + + def menu(label: str, group: str) -> dict: + return { + "label": label, + "message": f"【技能路由:ai-solid-wood-cost】打开修改菜单:{group}", + "intent": "open_quote_edit_menu", + } + + actions = [menu("修改基础用材", "basic")] + if self._is_panel_selection(selections): + actions.append(menu("修改板材参数", "panel")) + if not self._is_all_metal_selection(selections): + actions.append(menu("修改饰面五金", "surface")) + if self._is_metal_selection(selections): + actions.append(menu("修改金属参数", "metal")) + actions.extend(self._confirmation_edit_actions(project, "production")) + return actions + + def _confirmation_edit_menu_answer(self, project: dict, group: str) -> str: + titles = { + "basic": "修改基础与用材", + "panel": "修改板材参数", + "surface": "修改饰面、油漆与五金", + "metal": "修改金属参数", + "production": "修改生产与包装", + } + actions = self._confirmation_edit_actions(project, group) + actions.append( + { + "label": "返回报价确认", + "message": "【技能路由:ai-solid-wood-cost】返回报价确认", + "intent": "back_to_quote_confirmation", + } + ) + return ( + f"### {titles.get(group, '修改报价条件')}\n\n" + "> **当前步骤:** 只修改你选中的一项;其他条件、已上传图片和BOM均保留。\n\n" + + self._quick_replies("选择要修改的步骤", actions) + ) + + def _begin_confirmation_edit(self, project: dict, target: str) -> dict | None: + """Reopen exactly one configuration step and preserve unrelated answers.""" + material_dependency_keys = ( + "material_structure", "main_species", "aux_species", "all_same", "material_family", "panel_code", + "panel_thickness_mode", "panel_thickness_mm", "panel_main_thickness_mm", "panel_thin_thickness_mm", + "emission_grade", "panel_purchase_state", "hardware_grade", + "edge_material", "edge_thickness_mm", "edge_code", "edge_scope", "custom_edge_length_m", + "finish", "finish_color", "finish_gloss", "finish_texture", + "metal_component_scope", "metal_component_keywords", "metal_material_code", "metal_material_name", + "metal_profile_shape", "metal_profile_width_mm", "metal_profile_height_mm", "metal_profile_diameter_mm", + "metal_profile_display", "metal_wall_thickness_mm", "metal_joining", "metal_joint_count", + "metal_weld_coverage", "metal_grind_coverage", "metal_finish", + ) + specs = { + "评估用途": ("waiting_cost_purpose", ("cost_purpose",), "等待重新选择评估用途"), + "用材结构": ("waiting_material_structure", material_dependency_keys, "等待重新选择用材结构"), + "木质材料": ("waiting_material", ("main_species", "aux_species", "all_same", "material_family", "panel_code"), "等待重新选择木材或板材"), + "板材厚度": ("waiting_panel_thickness", ("panel_thickness_mode", "panel_thickness_mm", "panel_main_thickness_mm", "panel_thin_thickness_mm"), "等待重新确认板材厚度"), + "环保等级": ("waiting_emission_grade", ("emission_grade",), "等待重新选择环保等级"), + "板材采购状态": ("waiting_panel_purchase", ("panel_purchase_state", "finish", "finish_color", "finish_gloss", "finish_texture"), "等待重新选择板材采购状态"), + "封边材质": ("waiting_edge_material", ("edge_material", "edge_thickness_mm", "edge_code"), "等待重新选择封边材质"), + "封边厚度": ("waiting_edge_thickness", ("edge_thickness_mm", "edge_code"), "等待重新选择封边厚度"), + "封边范围": ("waiting_edge_scope", ("edge_scope", "custom_edge_length_m"), "等待重新选择封边范围"), + "五金档次": ("waiting_hardware", ("hardware_grade",), "等待重新选择五金档次"), + "饰面油漆": ("waiting_finish", ("finish", "finish_color", "finish_gloss", "finish_texture"), "等待重新选择饰面或油漆"), + "油漆外观": ("waiting_finish_details", ("finish_color", "finish_gloss", "finish_texture"), "等待重新确认油漆外观"), + "金属部件范围": ("waiting_metal_scope", ("metal_component_scope", "metal_component_keywords"), "等待重新确认金属部件范围"), + "金属材质": ("waiting_metal_material", ("metal_material_code", "metal_material_name", "metal_joining", "metal_joint_count", "metal_weld_coverage", "metal_grind_coverage", "metal_finish"), "等待重新选择金属材质"), + "金属型材": ("waiting_metal_profile", ("metal_profile_shape", "metal_profile_width_mm", "metal_profile_height_mm", "metal_profile_diameter_mm", "metal_profile_display", "metal_wall_thickness_mm"), "等待重新选择金属型材"), + "金属壁厚": ("waiting_metal_wall", ("metal_wall_thickness_mm",), "等待重新选择金属壁厚"), + "金属连接": ("waiting_metal_joining", ("metal_joining", "metal_joint_count", "metal_weld_coverage", "metal_grind_coverage"), "等待重新选择连接或焊接工艺"), + "金属表面": ("waiting_metal_finish", ("metal_finish",), "等待重新选择金属表面处理"), + "雕刻异形工艺": ("waiting_process", ("process_features_confirmed", "carving_confirmed"), "等待重新确认雕刻和异形工艺"), + "包装方式": ("waiting_packaging_mode", ("shipment_mode", "packaging_box_style", "carton_grade", "package_count", "package_count_explicit", "use_pallet", "packaging_profile"), "等待重新选择拆装或整装包装"), + "包装箱型": ("waiting_packaging_box_style", ("packaging_box_style",), "等待重新选择中开箱、天地盖或围板天地盖"), + "纸箱等级": ("waiting_carton_grade", ("carton_grade",), "等待重新选择简装级、经济级、标准级或加强级纸箱"), + } + spec = specs.get(str(target or "").strip()) + if not spec: + return None + stage, keys, display_text = spec + selections = dict(project.get("selections") or {}) + for key in (*keys, "final_confirmed", "post_quote_step", "wholesale_1688_enabled", "special_operation_codes", "special_operation_material_basis", "invoice_tax_enabled", "profit_margin"): + selections.pop(key, None) + updated = self._update_project( + project["project_id"], stage, display_text, selections=selections, result={} + ) + return {"status": "waiting", "answer": self._answer_for_stage(stage, updated), "files": [], "project_id": project["project_id"]} + + def _confirmation_answer(self, project: dict) -> str: + selections = project.get("selections") or {} + analysis = project.get("analysis") or {} + rows = [ + ("评估用途", selections.get("cost_purpose", "概算")), + ("产品类型", analysis.get("product_type", "家具")), + ("用材结构", selections.get("material_structure", "待确认")), + ] + if not self._is_all_metal_selection(selections): + rows.append(("木质材料", selections.get("main_species", "待确认"))) + if self._is_panel_selection(selections): + if selections.get("panel_thickness_mode") == "bom": + thickness = "按图片和尺寸图自动识别" + elif selections.get("panel_thickness_mode") == "grouped": + thickness = f"主体{float(selections.get('panel_main_thickness_mm') or 0):g}mm / 背板和抽底{float(selections.get('panel_thin_thickness_mm') or 0):g}mm" + else: + thickness = f"统一{float(selections.get('panel_thickness_mm') or 0):g}mm" + rows.extend( + [ + ("板厚", thickness), + ("环保等级", selections.get("emission_grade", "待确认")), + ("采购状态", selections.get("panel_purchase_state", "待确认")), + ("封边", f"{selections.get('edge_code', '')} / {selections.get('edge_scope', '')}"), + ] + ) + if any( + ("门" in str(item.get("name") or "")) + or ("抽屉" in str(item.get("name") or "") and "底" not in str(item.get("name") or "")) + for item in analysis.get("components") or [] + ): + rows.append(("五金档次", self.HARDWARE_GRADE_LABELS.get(str(selections.get("hardware_grade") or "basic"), "基础量产五金"))) + rows.extend( + [ + ("木质饰面/油漆", "不适用(全金属)" if self._is_all_metal_selection(selections) else selections.get("finish", "待确认")), + ("成本生产口径", f"批量生产(按{self.DEFAULT_BATCH_QTY}件/批摊销)"), + ("包装方式", selections.get("shipment_mode", "待确认")), + ( + "包装箱型", + self.PACKAGING_BOX_STYLES.get( + str(selections.get("packaging_box_style") or ""), {} + ).get("label", "待确认"), + ), + ( + "纸箱等级", + self.CARTON_GRADES.get( + str(selections.get("carton_grade") or ""), {} + ).get("label", "待确认"), + ), + ("运输场景", selections.get("shipping_scenario", "电商快递")), + ] + ) + if not self._is_all_metal_selection(selections): + preview_analysis = self._apply_production_joinery( + self._analysis_for_selected_thickness(analysis, selections), selections + ) + joinery = preview_analysis.get("production_joinery") or {} + if joinery.get("applicable"): + totals = joinery.get("totals") or {} + back = joinery.get("back_panel_groove") or {} + drawer = joinery.get("drawer_bottom_groove") or {} + rows.append(("生产连接", joinery.get("connection_system", "按量产孔位自动设计"))) + rows.append(("孔槽加工", f"孔{int(totals.get('holes') or 0)}个 / 槽{int(totals.get('grooves') or 0)}道 / 槽长{float(totals.get('groove_length_m') or 0):.3f}m")) + if back.get("enabled"): + rows.append(("背板连接槽", f"宽{float(back.get('width_mm') or 0):g}×深{float(back.get('depth_mm') or 0):g}mm,共{int(back.get('count') or 0)}道")) + if drawer.get("enabled"): + rows.append(("抽屉底板槽", f"宽{float(drawer.get('width_mm') or 0):g}×深{float(drawer.get('depth_mm') or 0):g}mm,共{int(drawer.get('count') or 0)}道")) + if self._is_metal_selection(selections): + joining_labels = { + "bolted": "螺栓拆装(无焊接)", + "weld_co2": "CO2/MAG焊", + "weld_tig": "TIG氩弧焊", + "weld_aluminum": "铝合金TIG焊", + } + rows.extend( + [ + ("金属材质", selections.get("metal_material_name", "待确认")), + ("金属型材", f"{selections.get('metal_profile_display', '待确认')} / 壁厚{float(selections.get('metal_wall_thickness_mm') or 0):g}mm"), + ("金属连接", f"{joining_labels.get(str(selections.get('metal_joining')), '待确认')} / {int(selections.get('metal_joint_count') or 0)}个接头 / 焊缝覆盖{float(selections.get('metal_weld_coverage') or 0):.0%}"), + ("金属表面", selections.get("metal_finish", "待确认")), + ] + ) + table = "\n".join(f"| {label} | {value} |" for label, value in rows) + return ( + "### 最终条件确认\n\n> **当前步骤:** 确认后才生成CAD、包装模拟、BOM和成本台账;连接孔位、背板槽、抽屉底板槽会实际进入CAD并驱动螺钉规格及加工成本。\n\n" + "| 条件 | 当前值 |\n| --- | --- |\n" + table + "\n\n" + + self._quick_replies( + "确认生成或单项修改", + [ + {"label": "确认生成报价", "message": "【技能路由:ai-solid-wood-cost】确认生成CAD和成本报价", "intent": "confirm_quote"}, + *self._confirmation_edit_menu_actions(project), + ], + ) + ) + + @staticmethod + def _production_ready(selections: dict) -> bool: + return bool( + selections.get("batch_qty") + and selections.get("shipment_mode") in {"整装", "拆装"} + and selections.get("packaging_box_style") in SolidWoodCostEngine.PACKAGING_BOX_STYLES + and selections.get("carton_grade") in SolidWoodCostEngine.CARTON_GRADES + and selections.get("shipping_scenario", "电商快递") in SolidWoodCostEngine.SHIPPING_SCENARIOS + ) + + def _production_answer(self, project: dict) -> str: + return self._batch_answer(project) + + def _next_configuration_stage(self, project: dict, selections: dict) -> str: + if not selections.get("cost_purpose"): + return "waiting_cost_purpose" + if not selections.get("material_structure"): + return "waiting_material_structure" + if not self._material_ready(selections): + return "waiting_material" + if self._is_panel_selection(selections): + if selections.get("panel_thickness_mode") not in {"bom", "uniform", "grouped"}: + return "waiting_panel_thickness" + if selections.get("emission_grade") not in {"UNGRADED", "E1", "E0", "ENF"}: + return "waiting_emission_grade" + if selections.get("panel_purchase_state") not in {"bare", "prefinished", "preveneered"}: + return "waiting_panel_purchase" + if not self._is_all_metal_selection(selections) and not selections.get("finish"): + return "waiting_finish" + if self._is_panel_selection(selections): + if not selections.get("edge_material"): + return "waiting_edge_material" + if float(selections.get("edge_thickness_mm") or 0) <= 0: + return "waiting_edge_thickness" + if selections.get("edge_scope") not in {"exposed_edges", "all_edges", "custom"}: + return "waiting_edge_scope" + if selections.get("edge_scope") == "custom" and float(selections.get("custom_edge_length_m") or 0) <= 0: + return "waiting_edge_scope" + analysis = project.get("analysis") or {} + has_door_or_drawer = any( + ("门" in str(item.get("name") or "")) + or ("抽屉" in str(item.get("name") or "") and "底" not in str(item.get("name") or "")) + for item in analysis.get("components") or [] + ) + if has_door_or_drawer and selections.get("hardware_grade") not in self.HARDWARE_GRADE_FACTORS: + return "waiting_hardware" + finish = self._finish_profile(selections) + if not self._is_all_metal_selection(selections) and int(finish.get("coats") or 0) > 0 and not ( + selections.get("finish_color") and selections.get("finish_gloss") and selections.get("finish_texture") + ): + return "waiting_finish_details" + if self._is_metal_selection(selections): + if not self._metal_scope_ready(project, selections): + return "waiting_metal_scope" + if str(selections.get("metal_material_code") or "") not in self.METAL_MATERIALS: + return "waiting_metal_material" + if str(selections.get("metal_profile_shape") or "") not in self.METAL_PROFILE_GROUPS: + return "waiting_metal_profile" + if float(selections.get("metal_wall_thickness_mm") or 0) <= 0: + return "waiting_metal_wall" + if str(selections.get("metal_joining") or "") not in {"bolted", "weld_co2", "weld_tig", "weld_aluminum"}: + return "waiting_metal_joining" + if int(selections.get("metal_joint_count") or 0) <= 0: + return "waiting_metal_joining" + if str(selections.get("metal_finish") or "") not in { + "raw", "phosphating_powder", "powder_coat", "wet_paint", "stainless_brush", "aluminum_anodize" + }: + return "waiting_metal_finish" + evidence = (project.get("analysis") or {}).get("process_evidence") or {} + if evidence.get("requires_confirmation") and not selections.get("process_features_confirmed"): + return "waiting_process" + selections["batch_qty"] = self.DEFAULT_BATCH_QTY + if selections.get("shipment_mode") not in {"整装", "拆装"}: + return "waiting_packaging_mode" + if selections.get("packaging_box_style") not in self.PACKAGING_BOX_STYLES: + return "waiting_packaging_box_style" + if selections.get("carton_grade") not in self.CARTON_GRADES: + return "waiting_carton_grade" + if not selections.get("final_confirmed"): + return "waiting_confirmation" + return "confirmed" + + def _answer_for_stage(self, stage: str, project: dict) -> str: + if ( + stage == "waiting_confirmation" + and (project.get("result") or {}).get("quote") + and (project.get("selections") or {}).get("post_quote_step") + ): + return self._post_quote_answer(project) + mapping = { + "waiting_cost_purpose": self._cost_purpose_answer, + "waiting_material_structure": self._material_structure_answer, + "waiting_material": self._material_answer, + "waiting_panel_specs": self._panel_thickness_answer, + "waiting_panel_thickness": self._panel_thickness_answer, + "waiting_emission_grade": self._emission_grade_answer, + "waiting_panel_purchase": self._panel_purchase_answer, + "waiting_finish": self._finish_answer, + "waiting_edge_material": self._edge_material_answer, + "waiting_edge_thickness": self._edge_thickness_answer, + "waiting_edge_scope": self._edge_scope_answer, + "waiting_hardware": self._hardware_answer, + "waiting_finish_details": self._finish_details_answer, + "waiting_metal_scope": self._metal_component_scope_answer, + "waiting_metal_material": self._metal_material_answer, + "waiting_metal_profile": self._metal_profile_answer, + "waiting_metal_wall": self._metal_wall_thickness_answer, + "waiting_metal_joining": self._metal_joining_answer, + "waiting_metal_finish": self._metal_finish_answer, + "waiting_process": self._process_answer, + "waiting_cad_review": self._cad_review_answer, + "waiting_batch": self._batch_answer, + "waiting_packaging_mode": self._packaging_mode_answer, + "waiting_packaging_box_style": self._packaging_box_style_answer, + "waiting_carton_grade": self._carton_grade_answer, + "waiting_production": self._batch_answer, + "waiting_confirmation": self._confirmation_answer, + } + handler = mapping.get(stage) + if handler: + return handler(project) + return f"### {self.DISPLAY_NAME}进度\n\n{project.get('display_text', '任务正在处理')}" + + def _load_ai3d(self): + if self._ai3d is not None: + return self._ai3d + spec = importlib.util.spec_from_file_location("dalongxia_shared_ai3d", self.ai3d_path) + if spec is None or spec.loader is None: + raise SolidWoodCostError("无法加载共享CAD引擎") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + self._ai3d = module.AI3DModelEngine() + return self._ai3d + + @classmethod + def _layout_dimensions_for_component(cls, analysis: dict, component: dict) -> dict: + """Map unordered AI dimensions onto the global width/depth/height axes.""" + raw = component.get("dimensions_mm") or {} + values = [max(1.0, float(raw.get(axis) or 1.0)) for axis in ("x", "y", "z")] + overall = analysis.get("overall_dimensions_mm") or {} + ox, oy, oz = (max(1.0, float(overall.get(axis) or 1.0)) for axis in ("x", "y", "z")) + key = cls._component_key(component) + thickness_index = min(range(3), key=lambda index: values[index]) + thickness = values[thickness_index] + planar = [value for index, value in enumerate(values) if index != thickness_index] + + def assign(first_target: float, second_target: float) -> tuple[float, float]: + first, second = planar + direct = abs(first - first_target) / first_target + abs(second - second_target) / second_target + swapped = abs(second - first_target) / first_target + abs(first - second_target) / second_target + return (first, second) if direct <= swapped else (second, first) + + # Drawer pieces have already been normalized to the product coordinate + # contract when a complete drawer box is created: fronts/backs use Y as + # thickness, side boards use X as thickness, and bottoms use Z as + # thickness. Generic substring rules (for example "后侧板") would + # otherwise rotate a drawer back into a cabinet side panel again. + if "抽屉" in key or "drawer" in key: + return {axis: values[index] for index, axis in enumerate(("x", "y", "z"))} + if cls._is_shelf_panel(component) or any(word in key for word in ("顶板", "底板", "top panel", "bottom panel")): + width, depth = assign(ox, oy) + return {"x": width, "y": depth, "z": thickness} + if any(word in key for word in ("侧板", "side panel")): + depth, height = assign(oy, oz) + return {"x": thickness, "y": depth, "z": height} + if any(word in key for word in ("门板", "柜门", "door")): + # A confirmed door schedule is already expressed in product axes + # (X=leaf width, Y=thickness, Z=leaf height). Do not re-solve the + # two planar axes against the *overall* cabinet proportions: on a + # 1200×850 cabinet that can incorrectly rotate a confirmed + # 600×12×540 sliding leaf into 540×12×600. + schedule = analysis.get("door_dimensions_mm") or {} + leaf_widths = [float(value) for value in (schedule.get("leaf_widths") or []) if float(value) > 0] + leaf_height = float(schedule.get("leaf_height") or 0.0) + direct_width = values[0] + direct_height = values[2] + tolerance = max(3.0, min(15.0, max(direct_width, direct_height) * 0.02)) + schedule_matches = ( + leaf_widths + and leaf_height > 0 + and min(abs(direct_width - value) for value in leaf_widths) <= tolerance + and abs(direct_height - leaf_height) <= tolerance + ) + evidence_text = json.dumps(component.get("dimension_evidence") or {}, ensure_ascii=False) + if schedule_matches or ( + component.get("confirmed_thickness_mm") is not None + and any(token in evidence_text for token in ("用户", "尺寸图", "确认")) + ): + return {"x": direct_width, "y": thickness, "z": direct_height} + width, height = assign(ox, oz) + return {"x": width, "y": thickness, "z": height} + if cls._is_back_panel(component): + width, height = assign(ox, oz) + return {"x": width, "y": thickness, "z": height} + if cls._is_leg_component(component): + ordered = sorted(values) + return {"x": ordered[0], "y": ordered[1], "z": ordered[2]} + return {axis: values[index] for index, axis in enumerate(("x", "y", "z"))} + + @classmethod + def _cabinet_vertical_metrics(cls, analysis: dict) -> dict: + overall = analysis.get("overall_dimensions_mm") or {} + overall_height = max(1.0, float(overall.get("z") or 1.0)) + top_thickness = 0.0 + bottom_thickness = 0.0 + leg_height = 0.0 + plinth_height = 0.0 + has_case_parts = False + for item in analysis.get("components") or []: + key = cls._component_key(item) + dims = cls._layout_dimensions_for_component(analysis, item) + if any(word in key for word in ("顶板", "top panel", "table top", "台面")): + top_thickness = max(top_thickness, dims["z"]) + has_case_parts = True + elif any(word in key for word in ("底板", "bottom panel")) and not cls._is_aux_component(item): + bottom_thickness = max(bottom_thickness, dims["z"]) + has_case_parts = True + elif any(word in key for word in ("侧板", "side panel", "门板", "柜门", "door")): + has_case_parts = True + if cls._is_leg_component(item): + leg_height = max(leg_height, dims["z"]) + if any(token in key for token in ("踢脚", "toe kick", "toe-kick", "plinth")): + plinth_height = max(plinth_height, dims["z"]) + if not has_case_parts or leg_height > overall_height * 0.4: + leg_height = 0.0 + return { + "leg_height": leg_height, + "support_height": max(leg_height, plinth_height), + "top_thickness": top_thickness, + "bottom_thickness": bottom_thickness, + } + + @classmethod + def _assembly_origin_for_instance( + cls, + analysis: dict, + component: dict, + item_index: int, + layout_dimensions: dict | None = None, + ) -> dict: + dims = layout_dimensions or cls._layout_dimensions_for_component(analysis, component) + dx, dy, dz = (float(dims.get(axis) or 1.0) for axis in ("x", "y", "z")) + overall = analysis.get("overall_dimensions_mm") or {} + ox, oy, oz = (float(overall.get(axis) or 1.0) for axis in ("x", "y", "z")) + quantity = max(1, int(component.get("quantity") or 1)) + key = cls._component_key(component) + metrics = cls._cabinet_vertical_metrics(analysis) + leg_height = float(metrics["leg_height"]) + support_height = float(metrics.get("support_height") or leg_height) + top_thickness = float(metrics["top_thickness"]) + bottom_thickness = float(metrics["bottom_thickness"]) + inner_bottom = support_height + bottom_thickness + inner_top = max(inner_bottom, oz - top_thickness) + inner_height = max(0.0, inner_top - inner_bottom) + contract = analysis.get("user_topology_constraints") or {} + + lower_divider = "下柜中间竖隔板" in key + drawer_support = any(token in key for token in ("抽屉下方", "抽屉支撑", "drawer support")) + drawer_separator = any(token in key for token in ("抽屉之间", "抽屉中隔", "drawer divider")) + if lower_divider: + heuristic = { + "x": max(0.0, (ox - dx) / 2.0), + "y": max(0.0, (oy - dy) / 2.0), + "z": inner_bottom, + } + elif drawer_support: + front_heights = [ + float(cls._layout_dimensions_for_component(analysis, item).get("z") or 0.0) + for item in (analysis.get("components") or []) if cls._is_drawer_front(item) + ] + front_height = max(front_heights, default=max(100.0, oz * 0.18)) + heuristic = { + "x": max(0.0, (ox - dx) / 2.0), + "y": max(0.0, (oy - dy) / 2.0), + "z": max(inner_bottom, inner_top - front_height - dz), + } + elif drawer_separator: + front_heights = [ + float(cls._layout_dimensions_for_component(analysis, item).get("z") or 0.0) + for item in (analysis.get("components") or []) if cls._is_drawer_front(item) + ] + front_height = max(front_heights, default=dz) + heuristic = { + "x": max(0.0, (ox - dx) / 2.0), + "y": max(0.0, (oy - dy) / 2.0), + "z": max(inner_bottom, inner_top - min(dz, front_height)), + } + elif cls._drawer_box_role(component): + sequence_match = re.search(r"第\s*(\d+)\s*个?抽屉", key) + if sequence_match: + drawer_index = max(0, int(sequence_match.group(1)) - 1) + else: + drawer_index = item_index + current_part_id = str(component.get("part_id") or "") + if cls._is_drawer_front(component): + front_cursor = 0 + for candidate in (analysis.get("components") or []): + if not cls._is_drawer_front(candidate): + continue + if candidate is component or ( + current_part_id and str(candidate.get("part_id") or "") == current_part_id + ): + drawer_index = front_cursor + item_index + break + front_cursor += max(1, int(candidate.get("quantity") or 1)) + else: + # Separate vision rows for drawer 1 and drawer 2 both have + # item_index=0. Address them by their ordinal within the + # same physical role so they cannot overlap drawer 1. + role = cls._drawer_box_role(component) + role_cursor = 0 + for candidate in (analysis.get("components") or []): + if cls._drawer_box_role(candidate) != role: + continue + if candidate is component or ( + current_part_id and str(candidate.get("part_id") or "") == current_part_id + ): + drawer_index = role_cursor + item_index + break + role_cursor += max(1, int(candidate.get("quantity") or 1)) + fronts: list[tuple[dict, dict, int]] = [] + for front in (analysis.get("components") or []): + if not cls._is_drawer_front(front): + continue + front_dims = cls._layout_dimensions_for_component(analysis, front) + for front_index in range(max(1, int(front.get("quantity") or 1))): + fronts.append((front, front_dims, front_index)) + if fronts: + drawer_index = min(drawer_index, len(fronts) - 1) + front_dims = fronts[drawer_index][1] + front_width = float(front_dims.get("x") or dx) + front_thickness = float(front_dims.get("y") or min(dx, dy, dz)) + front_height = float(front_dims.get("z") or dz) + drawer_layout = cls._drawer_layout(analysis) + if drawer_layout == "vertical": + drawer_x = max(0.0, (ox - front_width) / 2.0) + if contract.get("drawer_zone") == "bottom": + drawer_z = inner_bottom + sum( + float(fronts[index][1].get("z") or 0.0) + for index in range(drawer_index) + ) + else: + total_height = sum(float(item_dims.get("z") or 0.0) for _, item_dims, _ in fronts) + vertical_gap = max(0.0, (inner_height - total_height) / (len(fronts) + 1)) + drawer_z = inner_bottom + vertical_gap + sum( + float(fronts[index][1].get("z") or 0.0) + vertical_gap + for index in range(drawer_index) + ) + else: + total_width = sum(float(item_dims.get("x") or 0.0) for _, item_dims, _ in fronts) + gap = max(0.0, (ox - total_width) / (len(fronts) + 1)) + drawer_x = gap + sum( + float(fronts[index][1].get("x") or 0.0) + gap + for index in range(drawer_index) + ) + drawer_z = max(inner_bottom, inner_top - front_height) + if not cls._is_drawer_front(component): + # Every box belongs to its own front instance. Reusing its + # validated placement avoids floating generated side/bottom + # boards when the source places drawers in a lower module + # or supplies unequal heights/widths. + front_part, front_layout_dims, front_index = fronts[drawer_index] + anchor = cls._assembly_origin_for_instance(analysis, front_part, front_index, front_layout_dims) + drawer_x, drawer_z = float(anchor["x"]), float(anchor["z"]) + if cls._is_drawer_front(component): + heuristic = {"x": drawer_x, "y": 0.0, "z": drawer_z} + elif cls._drawer_box_role(component) == "left": + heuristic = {"x": drawer_x, "y": front_thickness, "z": drawer_z} + elif cls._drawer_box_role(component) == "right": + heuristic = {"x": max(drawer_x, drawer_x + front_width - dx), "y": front_thickness, "z": drawer_z} + elif cls._drawer_box_role(component) == "back": + side_depths = [ + float(cls._layout_dimensions_for_component(analysis, candidate).get("y") or 0.0) + for candidate in (analysis.get("components") or []) + if cls._drawer_box_role(candidate) in {"left", "right"} + ] + drawer_side_depth = max(side_depths, default=max(0.0, oy - front_thickness)) + heuristic = { + "x": drawer_x + max(0.0, (front_width - dx) / 2.0), + # The back closes the trailing end of the drawer side + # boards. Placing it at the cabinet rear face leaves a + # back-panel-thick gap whenever the drawer is shallower + # than the carcass. + "y": max(front_thickness, min(oy - dy, front_thickness + drawer_side_depth - dy)), + "z": drawer_z, + } + elif cls._drawer_box_role(component) == "bottom": + heuristic = { + "x": drawer_x + max(0.0, (front_width - dx) / 2.0), + "y": front_thickness, + "z": min(inner_top - dz, drawer_z + min(front_height * 0.12, 20.0)), + } + else: + heuristic = { + "x": drawer_x + max(0.0, (front_width - dx) / 2.0), + "y": front_thickness, + "z": drawer_z, + } + else: + heuristic = { + "x": max(0.0, (ox - dx) / 2), + "y": max(0.0, (oy - dy) / 2), + "z": inner_bottom + max(0.0, (inner_height - dz) / 2), + } + elif any(word in key for word in ("顶板", "top panel", "table top", "台面")): + top_y = max(0.0, oy - dy) if contract.get("side_profile") else max(0.0, (oy - dy) / 2) + heuristic = {"x": max(0.0, (ox - dx) / 2), "y": top_y, "z": max(0.0, oz - dz)} + elif any(word in key for word in ("底板", "bottom panel")) and not cls._is_aux_component(component): + heuristic = {"x": max(0.0, (ox - dx) / 2), "y": max(0.0, (oy - dy) / 2), "z": support_height} + elif any(word in key for word in ("侧板", "side panel")): + if any(word in key for word in ("右侧板", "侧板(右", "right side")): + x = max(0.0, ox - dx) + elif any(word in key for word in ("左侧板", "侧板(左", "left side")): + x = 0.0 + else: + x = 0.0 if item_index == 0 else max(0.0, ox - dx) + # A side panel whose confirmed height already equals the product's + # overall height includes the bottom/top envelope. Starting it on + # top of the bottom panel would make (for example) a 1600 mm unit + # render as 1612 mm high. Shorter inset sides still start at the + # inner-bottom datum. + side_z = 0.0 if dz >= oz - 0.5 else inner_bottom + max(0.0, (inner_height - dz) / 2) + heuristic = { + "x": x, + "y": max(0.0, (oy - dy) / 2), + "z": side_z, + } + elif any(word in key for word in ("门板", "柜门", "door")): + gap = max(0.0, (ox - dx * quantity) / (quantity + 1)) + heuristic = { + "x": gap + item_index * (dx + gap), + "y": 0.0, + "z": inner_bottom + max(0.0, (inner_height - dz) / 2), + } + elif cls._is_back_panel(component): + heuristic = { + "x": max(0.0, (ox - dx) / 2), + "y": max(0.0, oy - dy), + "z": inner_bottom + max(0.0, (inner_height - dz) / 2), + } + elif cls._is_shelf_panel(component): + if contract.get("shelf_layout") == "lower_left_right": + support_z = max(inner_bottom, inner_top - max( + ( + float(cls._layout_dimensions_for_component(analysis, item).get("z") or 0.0) + for item in (analysis.get("components") or []) if cls._is_drawer_front(item) + ), + default=max(100.0, oz * 0.18), + ) - dz) + left_x = max(0.0, (ox / 2.0 - dx) / 2.0) + right_x = ox / 2.0 + left_x + heuristic = { + "x": left_x if item_index % 2 == 0 else right_x, + "y": max(0.0, (oy - dy) / 2.0), + "z": inner_bottom + max(0.0, support_z - inner_bottom - dz) / 2.0, + } + elif contract.get("shelf_zone") == "above_drawers": + lower_height = max(inner_bottom, float(contract.get("lower_section_height_mm") or inner_bottom)) + usable = max(0.0, inner_top - lower_height - dz) + z = lower_height + usable * item_index / max(1, quantity) + heuristic = { + "x": max(0.0, (ox - dx) / 2.0), + "y": max(0.0, oy - dy), + "z": z, + } + else: + z = inner_bottom + max(0.0, inner_height - dz) * (item_index + 1) / (quantity + 1) + heuristic = {"x": max(0.0, (ox - dx) / 2), "y": max(0.0, (oy - dy) / 2), "z": z} + elif any(word in key for word in ("踢脚", "toe kick", "toe-kick", "plinth")): + heuristic = {"x": max(0.0, (ox - dx) / 2.0), "y": 0.0, "z": 0.0} + elif cls._is_leg_component(component) and quantity >= 4: + corners = ( + (0.0, 0.0), + (max(0.0, ox - dx), 0.0), + (0.0, max(0.0, oy - dy)), + (max(0.0, ox - dx), max(0.0, oy - dy)), + ) + x, y = corners[item_index % len(corners)] + heuristic = {"x": x, "y": y, "z": 0.0} + else: + heuristic = { + "x": max(0.0, (ox - dx) / 2), + "y": max(0.0, (oy - dy) / 2), + "z": max(0.0, (oz - dz) / 2), + } + + origins = [item for item in (component.get("origins_mm") or []) if isinstance(item, dict)] + if item_index >= len(origins): + return heuristic + candidate = { + axis: float(origins[item_index].get(axis) or 0.0) + for axis in ("x", "y", "z") + } + tolerance = max(5.0, min(30.0, max(ox, oy, oz) * 0.025)) + inside_product = all( + candidate[axis] >= -tolerance + and candidate[axis] + {"x": dx, "y": dy, "z": dz}[axis] + <= {"x": ox, "y": oy, "z": oz}[axis] + tolerance + for axis in ("x", "y", "z") + ) + known_layout_part = any( + word in key + for word in ( + "顶板", "底板", "侧板", "门板", "柜门", "背板", "搁板", "层板", "层隔板", + "横隔板", "竖隔板", "中隔板", "踢脚", + "支腿", "柜腿", "桌腿", "top panel", "bottom panel", "side panel", + "door", "back panel", "backboard", "rear panel", "shelf", "leg", + ) + ) + close_to_heuristic = all(abs(candidate[axis] - heuristic[axis]) <= tolerance for axis in ("x", "y", "z")) + canonical_origin = ( + "标准柜体产品坐标装配布局" in str(component.get("origin_source") or "") + or component.get("module_segment_index") is not None + ) + topology_driven = ( + (cls._is_shelf_panel(component) and contract.get("shelf_layout") in {"lower_left_right", "upper_open"}) + or (cls._drawer_box_role(component) and contract.get("drawer_zone") == "bottom") + or (any(token in key for token in ("顶板", "top panel")) and bool(contract.get("side_profile"))) + or any(token in key for token in ("下柜中间竖隔板", "抽屉下方", "抽屉之间", "踢脚")) + ) + if inside_product and not topology_driven and (canonical_origin or not known_layout_part or close_to_heuristic): + return candidate + return heuristic + + @classmethod + def _component_instances(cls, analysis: dict) -> list[dict]: + instances: list[dict] = [] + gap_mm = 50.0 + prepared = [ + ( + component_index, + component, + cls._layout_dimensions_for_component(analysis, component), + max(1, int(component.get("quantity") or 1)), + ) + for component_index, component in enumerate(analysis.get("components") or []) + ] + instance_count = sum(quantity for _, _, _, quantity in prepared) + grid_side = max(2, int(math.ceil(instance_count ** (1.0 / 3.0)))) if instance_count else 2 + max_dims = { + axis: max((float(dims.get(axis) or 1.0) for _, _, dims, _ in prepared), default=1.0) + for axis in ("x", "y", "z") + } + instance_ordinal = 0 + for component_index, component, dims, quantity in prepared: + part_id = str(component.get("part_id") or f"PART-{component_index + 1:03d}") + color = cls.COMPONENT_COLORS[component_index % len(cls.COMPONENT_COLORS)] + for item_index in range(quantity): + assembled_position = cls._assembly_origin_for_instance( + analysis, component, item_index, layout_dimensions=dims + ) + column = instance_ordinal % grid_side + row = (instance_ordinal // grid_side) % grid_side + layer = instance_ordinal // (grid_side * grid_side) + exploded_position = { + "x": column * (max_dims["x"] + gap_mm), + "y": row * (max_dims["y"] + gap_mm), + "z": layer * (max_dims["z"] + gap_mm), + } + instance_features = json.loads( + json.dumps(component.get("manufacturing_features") or {"holes": [], "grooves": []}, ensure_ascii=False) + ) + component_key = cls._component_key(component) + mirrored_side = ( + item_index % 2 == 1 + and quantity >= 2 + and any(token in component_key for token in ("侧板", "side panel", "left side", "right side")) + and "抽屉" not in component_key + ) + if mirrored_side: + for feature in list(instance_features.get("holes") or []) + list(instance_features.get("grooves") or []): + for center_key in ("center_mm", "requested_center_mm"): + center = feature.get(center_key) + if isinstance(center, dict) and "x" in center: + center["x"] = round(float(dims["x"]) - float(center["x"]), 3) + face = str(feature.get("face") or "") + if face == "X+": + feature["face"] = "X-" + elif face == "X-": + feature["face"] = "X+" + instances.append( + { + "node_name": f"{part_id}:{item_index + 1}", + "part_id": part_id, + "part_name": str(component.get("name") or ""), + "instance_index": item_index + 1, + "dimensions_mm": {axis: float(dims.get(axis) or 1.0) for axis in ("x", "y", "z")}, + "assembled_position_mm": assembled_position, + "exploded_position_mm": exploded_position, + "exploded_offset_mm": { + axis: round(exploded_position[axis] - assembled_position[axis], 3) + for axis in ("x", "y", "z") + }, + "color": color, + "draggable": True, + "manufacturing_features": instance_features, + "profile": dict(component.get("profile") or {}), + "mirrored_from_part_drawing": mirrored_side, + } + ) + instance_ordinal += 1 + return instances + + @staticmethod + def _component_net_geometry(item: dict) -> tuple[float, float]: + """Return per-piece net volume (m³) and primary face area (m²).""" + dims = item.get("dimensions_mm") or {} + x, y, z = (max(1.0, float(dims.get(axis) or 1.0)) for axis in ("x", "y", "z")) + profile = item.get("profile") or {} + if str(profile.get("type") or "") == "stepped_front_radius": + lower_depth = min(y, max(1.0, float(profile.get("lower_depth_mm") or y))) + upper_depth = min(lower_depth, max(1.0, float(profile.get("upper_depth_mm") or lower_depth))) + lower_height = min(z, max(1.0, float(profile.get("lower_height_mm") or z / 2.0))) + face_area_mm2 = lower_depth * lower_height + upper_depth * (z - lower_height) + # R-corners remove a small amount of material. Ignoring that + # sub-percent cutout is conservative and avoids false precision. + return x * face_area_mm2 / 1_000_000_000, face_area_mm2 / 1_000_000 + values = sorted((x, y, z)) + return x * y * z / 1_000_000_000, values[1] * values[2] / 1_000_000 + + @classmethod + def _source_for_components(cls, analysis: dict, *, layout: str = "exploded") -> str: + lines = [ + "from build123d import Align, Axis, Box, Color, Compound, Cylinder, Pos, fillet", + "", + "def gen_step():", + " parts = []", + ] + if layout not in {"assembled", "exploded"}: + raise ValueError(f"unsupported furniture layout: {layout}") + for instance in cls._component_instances(analysis): + dims = instance["dimensions_mm"] + position = instance[f"{layout}_position_mm"] + part_label = re.sub(r"[^A-Za-z0-9_:-]+", "-", instance["node_name"])[:80] + hex_color = str(instance["color"]).lstrip("#") + red, green, blue = ( + int(hex_color[index:index + 2], 16) / 255.0 for index in (0, 2, 4) + ) + variable = f"part_{len(lines)}" + profile = instance.get("profile") or {} + profile_type = str(profile.get("type") or "") + if profile_type == "stepped_front_radius": + lower_depth = min(float(dims["y"]), max(1.0, float(profile.get("lower_depth_mm") or dims["y"]))) + upper_depth = min(lower_depth, max(1.0, float(profile.get("upper_depth_mm") or lower_depth))) + lower_height = min(float(dims["z"]), max(1.0, float(profile.get("lower_height_mm") or dims["z"] / 2.0))) + upper_height = max(1.0, float(dims["z"]) - lower_height) + lower_variable = f"{variable}_lower" + upper_variable = f"{variable}_upper" + lines.append( + " {lower} = Box({x:.4f}, {y:.4f}, {z:.4f}, align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(Pos(0, {cy:.4f}, {cz:.4f}))".format( + lower=lower_variable, x=dims["x"], y=lower_depth, z=lower_height, + cy=(lower_depth - float(dims["y"])) / 2.0, + cz=(lower_height - float(dims["z"])) / 2.0, + ) + ) + lines.append( + " {upper} = Box({x:.4f}, {y:.4f}, {z:.4f}, align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(Pos(0, {cy:.4f}, {cz:.4f}))".format( + upper=upper_variable, x=dims["x"], y=upper_depth, z=upper_height, + cy=(float(dims["y"]) - upper_depth) / 2.0, + cz=lower_height / 2.0, + ) + ) + lines.append(f" {variable} = {lower_variable} + {upper_variable}") + radius = min( + max(0.0, float(profile.get("radius_mm") or 0.0)), + max(0.0, min(lower_depth, upper_depth, lower_height, upper_height) / 2.0 - 0.1), + ) + if radius > 0: + edge_variable = f"{variable}_step_profile_edges" + lower_front_y = -float(dims["y"]) / 2.0 + transition_z = -float(dims["z"]) / 2.0 + lower_height + upper_front_y = float(dims["y"]) / 2.0 - upper_depth + upper_top_z = float(dims["z"]) / 2.0 + lines.append( + " {edges} = [edge for edge in {variable}.edges().filter_by(Axis.X) if " + "(abs(edge.center().Y - {lower_y:.4f}) < 0.5 and abs(edge.center().Z - {transition_z:.4f}) < 0.5) or " + "(abs(edge.center().Y - {upper_y:.4f}) < 0.5 and abs(edge.center().Z - {upper_z:.4f}) < 0.5)]".format( + edges=edge_variable, variable=variable, + lower_y=lower_front_y, transition_z=transition_z, + upper_y=upper_front_y, upper_z=upper_top_z, + ) + ) + lines.append(f" if {edge_variable}:") + lines.append(f" {variable} = fillet({edge_variable}, {radius:.4f})") + else: + lines.append( + " {variable} = Box({x:.4f}, {y:.4f}, {z:.4f}, align=(Align.CENTER, Align.CENTER, Align.CENTER))".format( + variable=variable, x=dims["x"], y=dims["y"], z=dims["z"] + ) + ) + if profile_type == "front_top_radius": + radius = min( + max(0.0, float(profile.get("radius_mm") or 0.0)), + max(0.0, min(float(dims["y"]), float(dims["z"])) / 2.0 - 0.1), + ) + if radius > 0: + edge_variable = f"{variable}_profile_edges" + lines.append( + " {edges} = [edge for edge in {variable}.edges().filter_by(Axis.X) " + "if edge.center().Y < {front_y:.4f} and edge.center().Z > {half_z:.4f}]".format( + edges=edge_variable, + variable=variable, + front_y=-float(dims["y"]) * 0.49, + half_z=float(dims["z"]) * 0.49, + ) + ) + lines.append(f" {variable} = fillet({edge_variable}, {radius:.4f})") + features = instance.get("manufacturing_features") or {} + for feature_index, feature in enumerate(features.get("holes") or [], 1): + center = feature.get("center_mm") or {} + axis = str(feature.get("axis") or "z").lower() + cut_variable = f"{variable}_hole_{feature_index}" + rotate = "" + if axis == "x": + rotate = ".rotate(Axis.Y, 90)" + elif axis == "y": + rotate = ".rotate(Axis.X, 90)" + lines.append( + " {cut} = Cylinder({radius:.4f}, {depth:.4f}, align=(Align.CENTER, Align.CENTER, Align.CENTER)){rotate}.moved(Pos({cx:.4f}, {cy:.4f}, {cz:.4f}))".format( + cut=cut_variable, + radius=max(0.5, float(feature.get("diameter_mm") or 1.0) / 2), + depth=max(1.0, float(feature.get("depth_mm") or 1.0)), + rotate=rotate, + cx=float(center.get("x") or 0.0) - dims["x"] / 2, + cy=float(center.get("y") or 0.0) - dims["y"] / 2, + cz=float(center.get("z") or 0.0) - dims["z"] / 2, + ) + ) + lines.append(f" {variable} = {variable} - {cut_variable}") + for feature_index, feature in enumerate(features.get("grooves") or [], 1): + center = feature.get("center_mm") or {} + size = feature.get("size_mm") or {} + cut_variable = f"{variable}_groove_{feature_index}" + lines.append( + " {cut} = Box({sx:.4f}, {sy:.4f}, {sz:.4f}, align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(Pos({cx:.4f}, {cy:.4f}, {cz:.4f}))".format( + cut=cut_variable, + sx=max(0.8, float(size.get("x") or 0.8)), + sy=max(0.8, float(size.get("y") or 0.8)), + sz=max(0.8, float(size.get("z") or 0.8)), + cx=float(center.get("x") or 0.0) - dims["x"] / 2, + cy=float(center.get("y") or 0.0) - dims["y"] / 2, + cz=float(center.get("z") or 0.0) - dims["z"] / 2, + ) + ) + lines.append(f" {variable} = {variable} - {cut_variable}") + lines.append( + " {variable} = {variable}.moved(Pos({ox:.4f}, {oy:.4f}, {oz:.4f}))".format( + variable=variable, + ox=position["x"] + dims["x"] / 2, + oy=position["y"] + dims["y"] / 2, + oz=position["z"] + dims["z"] / 2, + ) + ) + lines.append(f" {variable}.label = {part_label!r}") + lines.append(f" {variable}.color = Color({red:.6f}, {green:.6f}, {blue:.6f})") + lines.append(f" parts.append({variable})") + lines.extend([f" return Compound(children=parts, label='furniture_{layout}')", ""]) + return "\n".join(lines) + + @classmethod + def _source_for_packaging(cls, packaging: dict) -> str: + lines = ["from build123d import Box, Color, Compound, Pos", "", "def gen_step():", " parts = []"] + package_offsets: dict[int, float] = {} + running_offset = 0.0 + for package in packaging.get("package_dimensions") or []: + package_index = int(package.get("package_index") or 1) + package_offsets[package_index] = running_offset + running_offset += float(package.get("x") or 1) + 100.0 + presentation_nodes = packaging.get("packaging_layers") or [] + if presentation_nodes: + source_items = presentation_nodes + else: + source_items = [ + { + **item, + "packed_position_mm": item.get("position_mm") or {}, + "exploded_position_mm": item.get("position_mm") or {}, + "color": cls.COMPONENT_COLORS[(index - 1) % len(cls.COMPONENT_COLORS)], + } + for index, item in enumerate(packaging.get("packing_layout") or [], 1) + ] + for index, item in enumerate(source_items, 1): + dims = item.get("dimensions_mm") or {} + pos = item.get("exploded_position_mm") or item.get("packed_position_mm") or {} + package_offset = package_offsets.get(int(item.get("package_index") or 1), 0.0) + variable = f"packaging_node_{index}" + lines.append( + " {v} = Box({x:.4f}, {y:.4f}, {z:.4f}).moved(Pos({px:.4f}, {py:.4f}, {pz:.4f}))".format( + v=variable, + x=float(dims.get("x") or 1), y=float(dims.get("y") or 1), z=float(dims.get("z") or 1), + px=package_offset + float(pos.get("x") or 0) + float(dims.get("x") or 1) / 2, + py=float(pos.get("y") or 0) + float(dims.get("y") or 1) / 2, + pz=float(pos.get("z") or 0) + float(dims.get("z") or 1) / 2, + ) + ) + label = re.sub(r"[^A-Za-z0-9_:-]+", "-", str(item.get("node_name") or f"packaging-{index}"))[:80] + color = str(item.get("color") or cls.COMPONENT_COLORS[(index - 1) % len(cls.COMPONENT_COLORS)]).lstrip("#") + red, green, blue = (int(color[offset:offset + 2], 16) / 255.0 for offset in (0, 2, 4)) + lines.append(f" {variable}.label = {label!r}") + lines.append(f" {variable}.color = Color({red:.6f}, {green:.6f}, {blue:.6f})") + lines.append(f" parts.append({variable})") + lines.extend([" return Compound(children=parts, label='packaging_system_exploded')", ""]) + return "\n".join(lines) + + @classmethod + def _write_interaction_manifest(cls, job_dir: pathlib.Path, project_id: str, analysis: dict, packaging: dict | None = None) -> str: + overall = analysis.get("overall_dimensions_mm") or {} + longest = max(float(overall.get(axis) or 1.0) for axis in ("x", "y", "z")) + payload = { + "schema": "dalongxia.furniture-model-interaction.v3", + "unit": "mm", + "coordinates": { + "cad": "x_right_y_back_z_up", + "glb": "x_right_y_up_z_forward", + "scale_mm_to_glb": 0.001, + "position_mapping": { + "glb_x": "cad_x_mm * 0.001", + "glb_y": "cad_z_mm * 0.001", + "glb_z": "-cad_y_mm * 0.001", + }, + }, + "default_variant": "assembled", + "models": { + "assembled": {"artifact_role": "assembled_3d", "layout": "assembled"}, + "exploded": {"artifact_role": "interactive_3d", "layout": "exploded"}, + "packaging": { + "artifact_role": "packaging_3d", + "layout": "packaging_exploded", + "initial_state": "packaging_exploded", + }, + }, + "capabilities": { + "select_part": True, + "drag_part": True, + "reset_part": True, + "reset_all": True, + "one_click_explode": True, + "one_click_assemble": True, + "animated_transition": True, + "explode_slider": True, + "snap_to_assembly": True, + "select_packaging_layer": True, + "one_click_packaging_explode": True, + "one_click_packaging_pack": True, + }, + "interaction_state": { + "initial": "assembled", + "states": ["assembled", "exploded", "packaging_exploded", "packaging_packed", "custom"], + "transform_basis": "assembled_model_initial_node_transform", + "position_mode": "add_mapped_offset_to_initial_node_position", + }, + "actions": [ + { + "id": "explode_all", + "label": "一键炸开", + "from_states": ["assembled", "custom", "exploded"], + "to_state": "exploded", + "target": "all_parts", + "offset_field": "exploded_offset_mm", + "duration_ms": 700, + "easing": "easeInOutCubic", + "interruptible": True, + }, + { + "id": "assemble_all", + "label": "一键还原", + "from_states": ["exploded", "custom", "assembled"], + "to_state": "assembled", + "target": "all_parts", + "offset_field": None, + "duration_ms": 700, + "easing": "easeInOutCubic", + "interruptible": True, + }, + { + "id": "packaging_pack", + "label": "合上包装", + "from_states": ["packaging_exploded", "packaging_packed"], + "to_state": "packaging_packed", + "target": "packaging_layers", + "nodes_field": "packaging.packaging_layers", + "offset_field": "packed_offset_from_exploded_mm", + "duration_ms": 700, + "easing": "easeInOutCubic", + "interruptible": True, + }, + { + "id": "packaging_explode", + "label": "展开包装", + "from_states": ["packaging_packed", "packaging_exploded"], + "to_state": "packaging_exploded", + "target": "packaging_layers", + "nodes_field": "packaging.packaging_layers", + "offset_field": None, + "duration_ms": 700, + "easing": "easeInOutCubic", + "interruptible": True, + }, + ], + "drag": { + "axes": ["x", "y", "z"], + "snap_distance_mm": 25, + "max_distance_mm": round(longest * 1.5, 3), + }, + "parts": cls._component_instances(analysis), + "packaging": packaging or {}, + } + path = job_dir / f"{project_id}-model-interaction.json" + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return str(path) + + def _selected_species_for_component(self, item: dict, selections: dict) -> str: + if selections.get("all_same"): + return str(selections["main_species"]) + if self._is_aux_component(item): + return str(selections.get("aux_species") or selections.get("main_species")) + return str(selections.get("main_species")) + + def _analysis_for_selected_thickness(self, analysis: dict, selections: dict) -> dict: + """Apply the confirmed panel gauge to CAD, BOM and costing together.""" + adjusted = json.loads(json.dumps(analysis, ensure_ascii=False)) + if not self._is_panel_selection(selections): + return adjusted + mode = str(selections.get("panel_thickness_mode") or "bom") + for item in adjusted.get("components") or []: + if self._metal_component(item, selections): + continue + dims = item.get("dimensions_mm") or {} + if len(dims) != 3: + continue + name = str(item.get("name") or "") + auxiliary = any(token in name for token in ("背板", "抽屉底", "抽底", "底衬", "内衬")) + target = 0.0 + if mode == "uniform": + target = float(selections.get("panel_thickness_mm") or 0.0) + elif mode == "grouped": + raw_target = ( + selections.get("panel_thin_thickness_mm") + if auxiliary + else selections.get("panel_main_thickness_mm") + ) + target = float(raw_target or 0.0) + if target <= 0: + continue + thickness_axis = min(dims, key=lambda axis: float(dims.get(axis) or 0.0)) + item["detected_dimensions_mm"] = dict(dims) + dims[thickness_axis] = target + item["confirmed_thickness_mm"] = target + if mode == "uniform": + target = float(selections.get("panel_thickness_mm") or 0.0) + adjusted["assumptions"] = [ + value for value in (adjusted.get("assumptions") or []) + if not ( + ("厚度" in str(value) and re.search(r"\d+(?:\.\d+)?\s*(?:mm|毫米)", str(value), re.I)) + or ("未明确标注的加工工艺" in str(value) and "按常规处理" in str(value)) + ) + ] + adjusted.setdefault("assumptions", []).append(f"用户确认所有板材统一{target:g}mm") + # Board gauge changes invalidate every origin inferred from the old + # gauge. Reflow the cabinet now; otherwise a 20→12mm confirmation + # leaves sides/top/doors at 20mm-era coordinates and the CAD exceeds + # the product envelope even though all individual dimensions are valid. + adjusted["components"] = self._canonicalize_simple_cabinet_origins( + adjusted.get("components") or [], + adjusted.get("overall_dimensions_mm") or {}, + has_doors=bool(adjusted.get("has_doors")), + door_mechanism=str(adjusted.get("door_mechanism") or ""), + ) + # Canonical cabinet reflow intentionally handles generic single-piece + # sides. Reapply the persisted topology contract afterwards so a user- + # confirmed modular side cannot collapse or overlap when the confirmed + # board gauge is applied just before CAD generation. + adjusted = self._apply_topology_geometry_contract(adjusted) + adjusted["process_evidence"] = self._process_evidence(adjusted) + return adjusted + + def _apply_production_joinery(self, analysis: dict, selections: dict) -> dict: + """Create one manufacturable joinery plan shared by CAD, BOM and costing. + + Vision can observe an existing hole, but it cannot see the concealed holes + and grooves that still have to be designed for production. This method is + deliberately deterministic: the confirmed board thickness and component + roles create physical features, exact fastener specifications and costing + drivers in one pass. Reapplying the same version is idempotent. + """ + adjusted = json.loads(json.dumps(analysis, ensure_ascii=False)) + existing = adjusted.get("production_joinery") or {} + if existing.get("schema") == "dalongxia.production-joinery.v2" and existing.get("cost_model_version") == self.COST_MODEL_VERSION: + return adjusted + rule_version, rule_rows = self._active_connection_rules() + template_version, hardware_templates = self._active_hardware_templates() + if self._is_all_metal_selection(selections): + adjusted["production_joinery"] = { + "schema": "dalongxia.production-joinery.v2", + "cost_model_version": self.COST_MODEL_VERSION, + "applicable": False, + "reason": "全金属产品不生成木质板件孔槽", + "hardware_lines": [], + "totals": {"holes": 0, "grooves": 0, "groove_length_m": 0.0}, + "connection_rule_version": rule_version.get("version_label"), + "hardware_template_version": template_version.get("version_label"), + } + return adjusted + + components = adjusted.get("components") or [] + wood_components = [item for item in components if not self._metal_component(item, selections)] + thicknesses = [ + min(self._layout_dimensions_for_component(adjusted, item).values()) + for item in wood_components + if not self._is_aux_component(item) + ] + panel_thickness = float(statistics.median(thicknesses)) if thicknesses else 18.0 + panel_mode = self._is_panel_selection(selections) + wood_material_family = "engineered_panel" if panel_mode else "solid_wood" + if panel_mode and panel_thickness <= 12.5: + screw_code, screw_name, screw_spec, screw_diameter, screw_depth = ( + "confirmat_5x30", "柜体确认螺钉", "5×30mm", 4.0, 25.0, + ) + elif panel_mode and panel_thickness <= 16.0: + screw_code, screw_name, screw_spec, screw_diameter, screw_depth = ( + "confirmat_6_3x40", "柜体确认螺钉", "6.3×40mm", 5.0, 32.0, + ) + elif panel_mode and panel_thickness <= 19.0: + screw_code, screw_name, screw_spec, screw_diameter, screw_depth = ( + "confirmat_7x50", "柜体确认螺钉", "7×50mm", 5.0, 40.0, + ) + elif panel_mode: + screw_code, screw_name, screw_spec, screw_diameter, screw_depth = ( + "confirmat_7x60", "柜体确认螺钉", "7×60mm", 5.0, 48.0, + ) + else: + screw_code, screw_name, screw_spec, screw_diameter, screw_depth = ( + "wood_screw_4x40", "柜体木螺钉", "4×40mm", 3.0, 32.0, + ) + + def key(item: dict) -> str: + return self._component_key(item) + + def is_drawer(item: dict) -> bool: + value = key(item) + return "抽屉" in value or "drawer" in value or "抽底" in value or "抽背" in value + + carcass_sides = [ + item for item in wood_components + if any(token in key(item) for token in ("侧板", "side panel", "left side", "right side")) + and not is_drawer(item) and not self._is_door_panel(item) + ] + carcass_tops = [ + item for item in wood_components + if any(token in key(item) for token in ("顶板", "top panel", "cabinet top", "台面")) + and not is_drawer(item) + ] + carcass_bottoms = [ + item for item in wood_components + if any(token in key(item) for token in ("柜体底板", "柜底板", "bottom panel", "cabinet bottom", "底板")) + and not is_drawer(item) and not self._is_back_panel(item) + ] + shelf_components = [item for item in wood_components if self._is_shelf_panel(item)] + back_panels = [item for item in wood_components if self._is_back_panel(item)] + drawer_sides = [ + item for item in wood_components + if self._drawer_box_role(item) in {"left", "right"} + ] + drawer_backs = [ + item for item in wood_components + if self._drawer_box_role(item) == "back" + ] + drawer_fronts = [item for item in wood_components if self._is_drawer_front(item)] + drawer_bottoms = [ + item for item in wood_components + if self._drawer_box_role(item) == "bottom" + ] + leg_components = [item for item in wood_components if self._is_leg_component(item)] + structure_decisions = adjusted.get("structure_decisions") or {} + leg_connection = dict(structure_decisions.get("leg_connection") or {}) + + rejected_features: list[dict] = [] + validation_issues: list[dict] = [] + for item in wood_components: + if "vision_machining" not in item: + item["vision_machining"] = dict(item.get("machining") or {}) + item["manufacturing_material_family"] = wood_material_family + item["manufacturing_features"] = { + "coordinate_system": { + "datum": "左-前-下角", + "origin_mm": {"x": 0.0, "y": 0.0, "z": 0.0}, + "axes": {"x": "宽度正方向", "y": "深度正方向", "z": "高度正方向"}, + "unit": "mm", + }, + "holes": [], + "grooves": [], + } + if panel_mode: + original_mortise = max(0, int((item.get("machining") or {}).get("mortise_tenon") or 0)) + if original_mortise: + rejected_features.append( + { + "part_id": str(item.get("part_id") or ""), + "part_name": str(item.get("name") or ""), + "process_code": "traditional_mortise_tenon", + "rejected_quantity": original_mortise, + "reason": "人造板不允许生成传统承力榫卯,已阻断该工艺和费用", + } + ) + item["machining"] = {**dict(item.get("machining") or {}), "mortise_tenon": 0} + + feature_sequence = 0 + + def add_hole( + item: dict, + *, + axis: str, + center: dict[str, float], + diameter: float, + depth: float, + feature_group: str, + feature_name: str, + hardware_code: str = "", + hardware_spec: str = "", + face_override: str = "", + ) -> None: + nonlocal feature_sequence + dims = self._layout_dimensions_for_component(adjusted, item) + requested_center = { + dim_axis: float(center.get(dim_axis) or 0.0) for dim_axis in ("x", "y", "z") + } + inside = all(0.0 <= requested_center[dim_axis] <= float(dims[dim_axis]) for dim_axis in ("x", "y", "z")) + safe_center = { + dim_axis: min(max(0.0, requested_center[dim_axis]), float(dims[dim_axis])) + for dim_axis in ("x", "y", "z") + } + template = hardware_templates.get(hardware_code) or {} + face = face_override or {"x": "X-", "y": "Y-", "z": "Z-"}.get(axis, "") + projection_axes = {"x": ("y", "z"), "y": ("x", "z"), "z": ("x", "y")}.get(axis, ("x", "y")) + validation_status = "valid" if inside and axis in {"x", "y", "z"} else "blocked" + feature_sequence += 1 + feature_id = f"HOLE-{feature_sequence:04d}" + if validation_status != "valid": + validation_issues.append( + { + "part_id": str(item.get("part_id") or ""), + "feature_id": feature_id, + "severity": "blocking", + "message": "孔位坐标超出零件边界,不得发布生产图", + "requested_center_mm": requested_center, + "part_dimensions_mm": dims, + } + ) + item["manufacturing_features"]["holes"].append( + { + "feature_id": feature_id, + "name": feature_name, + "group": feature_group, + "axis": axis, + "face": face, + "datum": "左-前-下角", + "requested_center_mm": requested_center, + "center_mm": safe_center, + "position_2d_mm": { + "u_axis": projection_axes[0], "u": round(safe_center[projection_axes[0]], 3), + "v_axis": projection_axes[1], "v": round(safe_center[projection_axes[1]], 3), + }, + "diameter_mm": round(max(1.0, float(diameter)), 3), + "depth_mm": round(max(1.0, float(depth)), 3), + "hole_type": "through" if float(depth) >= float(dims.get(axis) or 0.0) - 0.5 else "blind", + "dimension_tolerance_mm": 0.10, + "position_tolerance_mm": 0.15, + "hardware_code": hardware_code, + "hardware_spec": hardware_spec, + "hardware_template_id": str(template.get("template_id") or ""), + "hardware_template_parameters": dict(template.get("parameters") or {}), + "validation_status": validation_status, + "source": "量产家具连接规则", + } + ) + + def add_groove( + item: dict, + *, + center: dict[str, float], + size: dict[str, float], + length: float, + feature_group: str, + feature_name: str, + insert_thickness: float, + ) -> None: + nonlocal feature_sequence + dims = self._layout_dimensions_for_component(adjusted, item) + requested_size = {axis: float(size.get(axis) or 0.0) for axis in ("x", "y", "z")} + requested_center = {axis: float(center.get(axis) or 0.0) for axis in ("x", "y", "z")} + safe_size = { + axis: min(max(0.8, float(size.get(axis) or 0.8)), float(dims[axis])) + for axis in ("x", "y", "z") + } + safe_center = { + axis: min( + max(safe_size[axis] / 2, float(center.get(axis) or 0.0)), + max(safe_size[axis] / 2, float(dims[axis]) - safe_size[axis] / 2), + ) + for axis in ("x", "y", "z") + } + inside = all( + requested_size[axis] > 0 + and requested_size[axis] <= float(dims[axis]) + and requested_center[axis] - requested_size[axis] / 2 >= -0.001 + and requested_center[axis] + requested_size[axis] / 2 <= float(dims[axis]) + 0.001 + for axis in ("x", "y", "z") + ) + feature_sequence += 1 + feature_id = f"GROOVE-{feature_sequence:04d}" + if not inside: + validation_issues.append( + { + "part_id": str(item.get("part_id") or ""), + "feature_id": feature_id, + "severity": "blocking", + "message": "槽位或槽尺寸超出零件边界,不得发布生产图", + "requested_center_mm": requested_center, + "requested_size_mm": requested_size, + "part_dimensions_mm": dims, + } + ) + item["manufacturing_features"]["grooves"].append( + { + "feature_id": feature_id, + "name": feature_name, + "group": feature_group, + "face": "主加工面", + "datum": "左-前-下角", + "requested_center_mm": requested_center, + "center_mm": safe_center, + "requested_size_mm": requested_size, + "size_mm": safe_size, + "length_mm": round(max(0.0, float(length)), 3), + "insert_thickness_mm": round(max(0.0, float(insert_thickness)), 3), + "width_clearance_mm": 0.5, + "dimension_tolerance_mm": 0.15, + "bottom_radius_max_mm": 1.5, + "validation_status": "valid" if inside else "blocked", + "source": "量产家具开槽规则", + } + ) + + side_count = sum(max(1, int(item.get("quantity") or 1)) for item in carcass_sides) + top_count = sum(max(1, int(item.get("quantity") or 1)) for item in carcass_tops) + bottom_count = sum(max(1, int(item.get("quantity") or 1)) for item in carcass_bottoms) + end_piece_count = top_count + bottom_count + carcass_joint_count = side_count * end_piece_count + + # The cabinet shown by the assembled CAD has its side panels standing + # between the full-width top and bottom. The connection axis is therefore + # Z (through the horizontal panel into the side-panel end grain), not X. + # Keeping both mating bores on the same global axis prevents a visually + # plausible but physically impossible drilling schedule. + for item, is_top in [ + *((candidate, True) for candidate in carcass_tops), + *((candidate, False) for candidate in carcass_bottoms), + ]: + dims = self._layout_dimensions_for_component(adjusted, item) + interfaces = max(1, side_count) + for interface in range(interfaces): + from_left = interface < math.ceil(interfaces / 2) + x = panel_thickness / 2 if from_left else dims["x"] - panel_thickness / 2 + for fraction in (0.25, 0.75): + add_hole( + item, axis="z", center={"x": x, "y": dims["y"] * fraction, "z": dims["z"] / 2}, + diameter=screw_diameter, depth=dims["z"] + 1.0, + feature_group="carcass_connection", feature_name="柜体连接螺钉通孔", + hardware_code=screw_code, hardware_spec=screw_spec, + face_override="Z+" if is_top else "Z-", + ) + for fraction in (0.40, 0.60): + dowel_depth = min(12.0, dims["z"]) + add_hole( + item, + axis="z", + center={ + "x": x, + "y": dims["y"] * fraction, + "z": dowel_depth / 2 if is_top else dims["z"] - dowel_depth / 2, + }, + diameter=8.0, depth=dowel_depth, + feature_group="carcass_connection", feature_name="Φ8木榫孔", + hardware_code="wood_dowel_8x30", hardware_spec="Φ8×30mm", + face_override="Z-" if is_top else "Z+", + ) + for item in carcass_sides: + dims = self._layout_dimensions_for_component(adjusted, item) + for end_index, end_parts in ((0, bottom_count), (1, top_count)): + for _ in range(end_parts): + screw_center_z = ( + min(screw_depth, dims["z"]) / 2 + if end_index == 0 + else dims["z"] - min(screw_depth, dims["z"]) / 2 + ) + for fraction in (0.25, 0.75): + add_hole( + item, + axis="z", + center={"x": dims["x"] / 2, "y": dims["y"] * fraction, "z": screw_center_z}, + diameter=screw_diameter, depth=min(screw_depth, dims["z"]), + feature_group="carcass_connection", feature_name="侧板端部螺钉预孔", + hardware_code=screw_code, hardware_spec=screw_spec, + face_override="Z-" if end_index == 0 else "Z+", + ) + for fraction in (0.40, 0.60): + dowel_depth = min(24.0, dims["z"]) + dowel_center_z = dowel_depth / 2 if end_index == 0 else dims["z"] - dowel_depth / 2 + add_hole( + item, + axis="z", + center={"x": dims["x"] / 2, "y": dims["y"] * fraction, "z": dowel_center_z}, + diameter=8.0, depth=dowel_depth, + feature_group="carcass_connection", feature_name="侧板Φ8木榫孔", + hardware_code="wood_dowel_8x30", hardware_spec="Φ8×30mm", + face_override="Z-" if end_index == 0 else "Z+", + ) + + # Each loose shelf receives four 5mm support pins: two bores on each + # cabinet side. This makes the support physically present in the + # production drawing, hardware BOM and CNC cost driver exactly once. + shelf_count = sum(max(1, int(item.get("quantity") or 1)) for item in shelf_components) + if shelf_count and side_count >= 2: + for item in carcass_sides: + dims = self._layout_dimensions_for_component(adjusted, item) + for shelf_index in range(shelf_count): + support_z = dims["z"] * (shelf_index + 1) / (shelf_count + 1) + for depth_fraction in (0.22, 0.78): + add_hole( + item, + axis="x", + center={"x": dims["x"] - min(10.0, dims["x"]) / 2, "y": dims["y"] * depth_fraction, "z": support_z}, + diameter=5.0, + depth=min(10.0, dims["x"]), + feature_group="shelf_support", + feature_name="5mm层板托定位孔", + hardware_code="shelf_pin_5mm", + hardware_spec="5mm金属层板托", + face_override="X+", + ) + + back_thickness = 0.0 + back_fit: dict = {"status": "not_applicable"} + if back_panels: + back_thickness = float(statistics.median( + min(self._layout_dimensions_for_component(adjusted, item).values()) for item in back_panels + )) + back_groove_width = max(back_thickness + 0.5, 3.0) + back_groove_depth = min(6.0, max(2.5, panel_thickness * 0.35)) + side_heights = [self._layout_dimensions_for_component(adjusted, item)["z"] for item in carcass_sides] + side_thicknesses = [self._layout_dimensions_for_component(adjusted, item)["x"] for item in carcass_sides] + overall = adjusted.get("overall_dimensions_mm") or {} + opening_width = max( + 0.0, + float(overall.get("x") or 0.0) - 2 * (statistics.median(side_thicknesses) if side_thicknesses else panel_thickness), + ) + opening_height = statistics.median(side_heights) if side_heights else max( + 0.0, float(overall.get("z") or 0.0) - 2 * panel_thickness + ) + expected_back_width = opening_width + 2 * back_groove_depth + expected_back_height = opening_height + 2 * back_groove_depth + actual_back_widths = [self._layout_dimensions_for_component(adjusted, item)["x"] for item in back_panels] + actual_back_heights = [self._layout_dimensions_for_component(adjusted, item)["z"] for item in back_panels] + actual_back_width = statistics.median(actual_back_widths) + actual_back_height = statistics.median(actual_back_heights) + fit_ok = ( + abs(actual_back_width - expected_back_width) <= 1.0 + and abs(actual_back_height - expected_back_height) <= 1.0 + ) + back_fit = { + "status": "valid" if fit_ok else "blocking", + "mounting": "four_side_groove", + "opening_mm": {"width": round(opening_width, 3), "height": round(opening_height, 3)}, + "groove_depth_mm": round(back_groove_depth, 3), + "required_back_size_mm": { + "width": round(expected_back_width, 3), + "height": round(expected_back_height, 3), + "thickness": round(back_thickness, 3), + }, + "actual_back_size_mm": { + "width": round(actual_back_width, 3), + "height": round(actual_back_height, 3), + "thickness": round(back_thickness, 3), + }, + } + if not fit_ok: + validation_issues.append( + { + "part_id": str(back_panels[0].get("part_id") or "BACK-001"), + "severity": "blocking", + "code": "back_panel_groove_fit_mismatch", + "message": ( + f"背板{actual_back_width:g}×{actual_back_height:g}mm等于/接近净开口," + f"无法同时嵌入四边{back_groove_depth:g}mm槽;四边嵌槽需约" + f"{expected_back_width:g}×{expected_back_height:g}mm" + ), + "suggested_question": ( + f"请确认将背板改为{expected_back_width:g}×{back_thickness:g}×{expected_back_height:g}mm并采用四边" + f"{back_groove_depth:g}mm嵌槽,或提供其他背板固定方式" + ), + } + ) + for item in carcass_sides: + dims = self._layout_dimensions_for_component(adjusted, item) + length = max(1.0, dims["z"]) + add_groove( + item, + center={"x": dims["x"] - back_groove_depth / 2, "y": dims["y"] - 8.0 - back_groove_width / 2, "z": dims["z"] / 2}, + size={"x": back_groove_depth, "y": back_groove_width, "z": length}, + length=length, feature_group="back_panel_groove", feature_name="背板嵌入槽", + insert_thickness=back_thickness, + ) + for item, is_top in [ + *((candidate, True) for candidate in carcass_tops), + *((candidate, False) for candidate in carcass_bottoms), + ]: + dims = self._layout_dimensions_for_component(adjusted, item) + length = min(max(1.0, expected_back_width), dims["x"]) + add_groove( + item, + center={ + "x": dims["x"] / 2, + "y": dims["y"] - 8.0 - back_groove_width / 2, + "z": back_groove_depth / 2 if is_top else dims["z"] - back_groove_depth / 2, + }, + size={"x": length, "y": back_groove_width, "z": back_groove_depth}, + length=length, feature_group="back_panel_groove", feature_name="背板嵌入槽", + insert_thickness=back_thickness, + ) + else: + back_groove_width = back_groove_depth = 0.0 + + drawer_bottom_thickness = 0.0 + if drawer_bottoms: + drawer_bottom_thickness = float(statistics.median( + min(self._layout_dimensions_for_component(adjusted, item).values()) for item in drawer_bottoms + )) + drawer_groove_width = max(drawer_bottom_thickness + 0.5, 3.0) + drawer_groove_depth = min(5.0, max(2.5, panel_thickness * 0.30)) + for item in drawer_sides: + dims = self._layout_dimensions_for_component(adjusted, item) + length = max(1.0, dims["y"] - 12.0) + add_groove( + item, + center={"x": dims["x"] / 2, "y": dims["y"] / 2, "z": min(dims["z"] / 2, 10.0 + drawer_groove_width / 2)}, + size={"x": drawer_groove_depth, "y": length, "z": drawer_groove_width}, + length=length, feature_group="drawer_bottom_groove", feature_name="抽屉底板连接槽", + insert_thickness=drawer_bottom_thickness, + ) + for fraction in (0.22, 0.78, 0.36, 0.64): + add_hole( + item, axis="x", center={"x": dims["x"] / 2, "y": dims["y"] * fraction, "z": dims["z"] * 0.72}, + diameter=2.5, depth=dims["x"] + 1.0, + feature_group="drawer_connection", feature_name="抽屉箱体螺钉预孔", + hardware_code="drawer_screw_3_5x30", hardware_spec="3.5×30mm", + ) + for item in drawer_backs + drawer_fronts: + dims = self._layout_dimensions_for_component(adjusted, item) + run_axis = "x" if dims["x"] >= dims["y"] else "y" + length = max(1.0, dims[run_axis] - 12.0) + size = {"x": drawer_groove_depth, "y": drawer_groove_depth, "z": drawer_groove_width} + size[run_axis] = length + add_groove( + item, + center={"x": dims["x"] / 2, "y": dims["y"] / 2, "z": min(dims["z"] / 2, 10.0 + drawer_groove_width / 2)}, + size=size, length=length, feature_group="drawer_bottom_groove", feature_name="抽屉底板连接槽", + insert_thickness=drawer_bottom_thickness, + ) + else: + drawer_groove_width = drawer_groove_depth = 0.0 + + leg_count = sum(max(1, int(item.get("quantity") or 1)) for item in leg_components) + leg_connection_details: dict = { + "enabled": False, + "leg_count": int(leg_count), + "connection_code": "", + } + if leg_count and str(leg_connection.get("connection_code") or "") == "m8_hanger_bolt": + leg_connection_details = { + "enabled": True, + "leg_count": int(leg_count), + "connection_code": "m8_hanger_bolt", + "hanger_bolt_template_id": str((hardware_templates.get("leg_hanger_bolt_m8") or {}).get("template_id") or ""), + "insert_template_id": str((hardware_templates.get("threaded_insert_m8") or {}).get("template_id") or ""), + "source": str(leg_connection.get("source") or "用户确认采用M8吊脚螺栓+预埋螺母"), + } + bottom_item = carcass_bottoms[0] if carcass_bottoms else None + if bottom_item is None: + validation_issues.append( + { + "severity": "blocking", + "code": "leg_connection_bottom_missing", + "message": "已确认柜腿螺栓连接,但未找到可安装预埋螺母的柜体底板", + "suggested_question": "请补齐柜体底板后重新生成CAD", + } + ) + else: + bottom_dims = self._layout_dimensions_for_component(adjusted, bottom_item) + bottom_origin = dict(((bottom_item.get("origins_mm") or [{}])[0]) or {}) + leg_centers: list[tuple[float, float]] = [] + for leg_item in leg_components: + leg_dims = self._layout_dimensions_for_component(adjusted, leg_item) + origins = list(leg_item.get("origins_mm") or []) + quantity = max(1, int(leg_item.get("quantity") or 1)) + if not origins: + origins = [ + {"x": 0.0, "y": 0.0}, + {"x": max(0.0, bottom_dims["x"] - leg_dims["x"]), "y": 0.0}, + {"x": 0.0, "y": max(0.0, bottom_dims["y"] - leg_dims["y"])}, + { + "x": max(0.0, bottom_dims["x"] - leg_dims["x"]), + "y": max(0.0, bottom_dims["y"] - leg_dims["y"]), + }, + ][:quantity] + for origin in origins[:quantity]: + leg_centers.append( + ( + float(origin.get("x") or 0.0) + leg_dims["x"] / 2 - float(bottom_origin.get("x") or 0.0), + float(origin.get("y") or 0.0) + leg_dims["y"] / 2 - float(bottom_origin.get("y") or 0.0), + ) + ) + leg_hole_depth = min(25.0, leg_dims["z"]) + add_hole( + leg_item, + axis="z", + center={"x": leg_dims["x"] / 2, "y": leg_dims["y"] / 2, "z": leg_dims["z"] - leg_hole_depth / 2}, + diameter=6.0, + depth=leg_hole_depth, + feature_group="leg_connection", + feature_name="M8柜腿吊脚螺栓预孔", + hardware_code="leg_hanger_bolt_m8", + hardware_spec="M8", + face_override="Z+", + ) + insert_depth = min(15.0, bottom_dims["z"]) + for center_x, center_y in leg_centers[:leg_count]: + add_hole( + bottom_item, + axis="z", + center={"x": center_x, "y": center_y, "z": insert_depth / 2}, + diameter=10.0, + depth=insert_depth, + feature_group="leg_connection", + feature_name="M8预埋螺母底孔", + hardware_code="threaded_insert_m8", + hardware_spec="M8", + face_override="Z-", + ) + elif leg_count: + validation_issues.append( + { + "part_id": str(leg_components[0].get("part_id") or ""), + "severity": "blocking", + "code": "leg_connection_unspecified", + "message": f"识别到{leg_count}条柜腿,但没有柜腿与柜体底板的连接方式、孔位和连接件", + "suggested_question": "请确认是否采用M8吊脚螺栓+预埋螺母,或提供实际柜腿连接件/孔位图", + } + ) + + total_holes = total_grooves = 0 + groove_length_m = 0.0 + group_counts: dict[str, int] = {} + for item in wood_components: + quantity = max(1, int(item.get("quantity") or 1)) + features = item.get("manufacturing_features") or {} + holes_for_unit = list(features.get("holes") or []) + grooves_for_unit = list(features.get("grooves") or []) + total_holes += len(holes_for_unit) * quantity + total_grooves += len(grooves_for_unit) * quantity + groove_length_m += sum(float(feature.get("length_mm") or 0.0) for feature in grooves_for_unit) * quantity / 1000 + for feature in holes_for_unit + grooves_for_unit: + group = str(feature.get("group") or "other") + group_counts[group] = group_counts.get(group, 0) + quantity + base = dict(item.get("vision_machining") or item.get("machining") or {}) + if panel_mode: + base["mortise_tenon"] = 0 + item["machining"] = { + **base, + "holes": max(int(base.get("holes") or 0), len(holes_for_unit)), + "slots": max(int(base.get("slots") or 0), len(grooves_for_unit)), + "slot_length_m": max(float(base.get("slot_length_m") or 0.0), sum(float(feature.get("length_mm") or 0.0) for feature in grooves_for_unit) / 1000), + "evidence": ";".join(filter(None, [str(base.get("evidence") or "").strip(), "生产连接方案已生成孔位、背板槽和抽屉底槽"])), + "confidence": max(float(base.get("confidence") or 0.0), 0.88 if holes_for_unit or grooves_for_unit else 0.0), + } + + hardware_lines: list[dict] = [] + + def hardware_line(code: str, name: str, spec: str, quantity: int, evidence: str) -> None: + if quantity <= 0: + return + unit_price = float(self.HARDWARE_UNIT_PRICES[code]) + hardware_lines.append( + { + "hardware_code": code, + "hardware_template_id": str((hardware_templates.get(code) or {}).get("template_id") or ""), + "name": f"{name}({spec})", + "specification": spec, + "unit": "个", + "quantity": int(quantity), + "unit_price": unit_price, + "cost": int(quantity) * unit_price, + "source": "CAD生产连接方案反推的规格与数量", + "evidence": evidence, + } + ) + + hardware_line( + screw_code, screw_name, screw_spec, carcass_joint_count * 2, + f"{side_count}件侧板与{end_piece_count}件顶/底板形成{carcass_joint_count}处连接,每处2颗", + ) + hardware_line( + "wood_dowel_8x30", "定位木榫", "Φ8×30mm", carcass_joint_count * 2, + f"柜体{carcass_joint_count}处连接,每处2根定位木榫", + ) + hardware_line( + "shelf_pin_5mm", "金属层板托", "5mm", shelf_count * 4, + f"{shelf_count}件层板,每件4个层板托;数量与侧板定位孔一致", + ) + if leg_connection_details.get("enabled"): + hardware_line( + "leg_hanger_bolt_m8", "柜腿吊脚螺栓", "M8", leg_count, + f"{leg_count}条柜腿,每条1支;数量与柜腿顶部预孔一致", + ) + hardware_line( + "threaded_insert_m8", "柜底预埋螺母", "M8", leg_count, + f"{leg_count}条柜腿,每条对应柜底1个预埋螺母;数量与柜底孔位一致", + ) + drawer_count = max(int(adjusted.get("drawer_count") or 0), self._drawer_front_count(components)) + complete_drawer_sides = sum(max(1, int(item.get("quantity") or 1)) for item in drawer_sides) + drawer_screw_count = min(drawer_count * 8, complete_drawer_sides * 4) + hardware_line( + "drawer_screw_3_5x30", "抽屉箱体连接螺钉", "3.5×30mm", drawer_screw_count, + f"{drawer_count}个抽屉,左右帮板每件4个预孔;数量与CAD孔位一致", + ) + + adjusted["production_joinery"] = { + "schema": "dalongxia.production-joinery.v2", + "cost_model_version": self.COST_MODEL_VERSION, + "applicable": bool(total_holes or total_grooves), + "connection_system": f"{screw_name}{screw_spec}+Φ8×30mm定位木榫", + "panel_thickness_mm": round(panel_thickness, 3), + "back_panel_groove": { + "enabled": bool(back_panels and group_counts.get("back_panel_groove")), + "insert_thickness_mm": round(back_thickness, 3), + "width_mm": round(back_groove_width, 3), + "depth_mm": round(back_groove_depth, 3), + "count": int(group_counts.get("back_panel_groove", 0)), + }, + "back_panel_fit": back_fit, + "shelf_support": { + "enabled": bool(shelf_count and group_counts.get("shelf_support")), + "shelf_count": int(shelf_count), + "pin_quantity": int(shelf_count * 4), + "hole_diameter_mm": 5.0, + "template_id": str((hardware_templates.get("shelf_pin_5mm") or {}).get("template_id") or ""), + }, + "drawer_bottom_groove": { + "enabled": bool(drawer_bottoms and group_counts.get("drawer_bottom_groove")), + "insert_thickness_mm": round(drawer_bottom_thickness, 3), + "width_mm": round(drawer_groove_width, 3), + "depth_mm": round(drawer_groove_depth, 3), + "count": int(group_counts.get("drawer_bottom_groove", 0)), + }, + "leg_connection": leg_connection_details, + "hardware_lines": hardware_lines, + "totals": { + "holes": int(total_holes), + "grooves": int(total_grooves), + "groove_length_m": round(groove_length_m, 4), + "carcass_connections": int(carcass_joint_count), + "drawer_connections": int(drawer_screw_count), + }, + "source": "基于已确认板厚、BOM角色和量产装配关系生成;没有把连接件材料或孔槽人工重复计价", + "confidence": 0.88, + "material_family": wood_material_family, + "connection_rule_version": rule_version.get("version_label"), + "hardware_template_version": template_version.get("version_label"), + "material_rules": [ + row for row in rule_rows if str(row.get("material_family") or "") in {wood_material_family, "mixed"} + ], + "rejected_incompatible_features": rejected_features, + "validation_issues": validation_issues, + "manufacturing_release_note": ( + "孔槽已绑定材料规则、零件基准和连接件模板;若有坐标越界、低置信或未绑定品牌五金,只能输出生产草案" + ), + } + adjusted["process_evidence"] = self._process_evidence(adjusted) + return adjusted + + @classmethod + def _item_edge_length_m(cls, item: dict, scope: str) -> tuple[float, str]: + dims = sorted((float(value) for value in (item.get("dimensions_mm") or {}).values()), reverse=True) + if len(dims) != 3: + return 0.0, "无有效板件尺寸" + quantity = max(1, int(item.get("quantity") or 1)) + full = 2 * (dims[0] + dims[1]) * quantity / 1000 + if scope == "all_edges": + return full, "全部板件四边" + key = cls._component_key(item) + edge_banding = item.get("edge_banding") or {} + explicit = float(edge_banding.get("exposed_length_m") or 0.0) + if edge_banding.get("confirmed"): + return max(0.0, explicit) * quantity, "抽屉/部件完整性规则确认(含封边胶水)" + if explicit > 0: + return explicit * quantity, "读图/BOM明确外露边(含封边胶水)" + if any(word in key for word in ("背板", "抽屉底", "底衬", "内衬")): + return 0.0, "内藏薄板默认无外露封边" + if any(word in key for word in ("门板", "柜门", "抽面", "抽屉面板", "drawer front")): + return full, "门板/抽面四周外露" + if any(word in key for word in ("抽屉后帮", "抽背", "drawer back")): + return 0.0, "抽屉内部后帮默认不封边" + if any(word in key for word in ("层板", "搁板", "顶板", "底板", "侧板", "shelf", "panel")): + return dims[0] * quantity / 1000, "普通柜体板默认仅前沿外露" + return full * 0.5, "未分类板件按半周长估算外露边" + + @staticmethod + def _box_surface_area_m2(dimensions_mm: dict) -> float: + x = float(dimensions_mm.get("x") or 0) + y = float(dimensions_mm.get("y") or 0) + z = float(dimensions_mm.get("z") or 0) + return 2 * (x * y + x * z + y * z) / 1_000_000 + + @classmethod + def _recommended_packaging_box_style(cls, analysis: dict, selections: dict) -> str: + overall = analysis.get("overall_dimensions_mm") or {} + dimensions = [float(overall.get(axis) or 0.0) for axis in ("x", "y", "z")] + longest = max(dimensions, default=0.0) + volume_m3 = math.prod(max(1.0, value) for value in dimensions) / 1_000_000_000 + shipment_mode = str(selections.get("shipment_mode") or "拆装") + scenario = str(selections.get("shipping_scenario") or "电商快递") + if (scenario == "跨境运输" and (longest > 1600 or volume_m3 > 0.45)) or longest > 2200: + return "sleeve_lid_base" + if shipment_mode == "整装": + return "telescopic_lid_base" + return "rsc_center_open" + + @classmethod + def _packaging_box_style(cls, analysis: dict, selections: dict) -> tuple[str, dict, bool]: + requested = str(selections.get("packaging_box_style") or "") + recommended = cls._recommended_packaging_box_style(analysis, selections) + code = requested if requested in cls.PACKAGING_BOX_STYLES else recommended + return code, dict(cls.PACKAGING_BOX_STYLES[code]), code == recommended + + @classmethod + def _recommended_carton_grade(cls, analysis: dict, selections: dict) -> str: + overall = analysis.get("overall_dimensions_mm") or {} + longest = max((float(overall.get(axis) or 0.0) for axis in ("x", "y", "z")), default=0.0) + volume_m3 = math.prod(max(1.0, float(overall.get(axis) or 1.0)) for axis in ("x", "y", "z")) / 1_000_000_000 + scenario = str(selections.get("shipping_scenario") or "电商快递") + shipment_mode = str(selections.get("shipment_mode") or "拆装") + if scenario == "跨境运输" or longest > 2200 or volume_m3 > 0.65: + return "company_reinforced" + if shipment_mode == "整装" or (not cls._is_panel_selection(selections) and longest > 1400): + return "company_standard" + if scenario == "国内专线" and shipment_mode == "拆装" and longest <= 1200 and volume_m3 <= 0.35: + return "company_basic" + return "company_economy" + + @classmethod + def _carton_grade(cls, analysis: dict, selections: dict) -> tuple[str, dict, bool]: + requested = str(selections.get("carton_grade") or "") + recommended = cls._recommended_carton_grade(analysis, selections) + code = requested if requested in cls.CARTON_GRADES else recommended + return code, dict(cls.CARTON_GRADES[code]), code == recommended + + @staticmethod + def _carton_blank_area_m2(dimensions_mm: dict, style_code: str) -> float: + """Estimate corrugated blank area from the selected FEFCO style family. + + L/W/H are internal dimensions. The formula is intentionally kept as a + transparent estimating driver; the final die-line remains a carton + supplier deliverable and must replace this estimate before production. + """ + x = max(1.0, float(dimensions_mm.get("x") or 1.0)) + y = max(1.0, float(dimensions_mm.get("y") or 1.0)) + height = max(1.0, float(dimensions_mm.get("z") or 1.0)) + length, width = max(x, y), min(x, y) + joint = 40.0 + if style_code == "telescopic_lid_base": + wall = min(height, max(60.0, height * 0.55)) + base = (length + 2 * wall) * (width + 2 * wall) + lid_wall = min(height, wall + 10.0) + lid = (length + 12.0 + 2 * lid_wall) * (width + 12.0 + 2 * lid_wall) + return (base + lid) * 1.08 / 1_000_000 + if style_code == "sleeve_lid_base": + tray_wall = min(height, max(55.0, height * 0.22)) + trays = 2 * (length + 2 * tray_wall) * (width + 2 * tray_wall) + sleeve = (2 * (length + width) + joint) * height + return (trays + sleeve) * 1.08 / 1_000_000 + # FEFCO 0201-style regular slotted container: one manufacturer's joint, + # four body panels and top/bottom flaps whose combined depth is W. + return (2 * (length + width) + joint) * (height + width) * 1.02 / 1_000_000 + + @classmethod + def _packaging_layer_nodes( + cls, + package_dimensions: list[dict], + packing_layout: list[dict], + style_code: str, + buffer_mm: float, + carton_material_code: str, + ) -> list[dict]: + """Build the packed and presentation/exploded packaging layer plan.""" + nodes: list[dict] = [] + carton_t = 5.0 + + def add_node( + package_index: int, + role: str, + name: str, + dimensions: dict, + packed_position: dict, + exploded_z: float, + color: str, + material_code: str, + ) -> None: + dims = {axis: max(0.8, float(dimensions.get(axis) or 0.8)) for axis in ("x", "y", "z")} + packed = {axis: float(packed_position.get(axis) or 0.0) for axis in ("x", "y", "z")} + exploded = {"x": packed["x"], "y": packed["y"], "z": float(exploded_z)} + nodes.append( + { + "package_index": package_index, + "node_name": f"PKG-{package_index:02d}-{role}-{len(nodes) + 1:03d}", + "node_role": role, + "name": name, + "material_code": material_code, + "dimensions_mm": dims, + "packed_position_mm": packed, + "exploded_position_mm": exploded, + "exploded_offset_mm": { + axis: round(exploded[axis] - packed[axis], 4) for axis in ("x", "y", "z") + }, + "packed_offset_from_exploded_mm": { + axis: round(packed[axis] - exploded[axis], 4) for axis in ("x", "y", "z") + }, + "color": color, + } + ) + + for package in package_dimensions: + package_index = int(package.get("package_index") or 1) + x, y, z = (max(1.0, float(package.get(axis) or 1.0)) for axis in ("x", "y", "z")) + inner_x, inner_y = max(1.0, x - 2 * carton_t), max(1.0, y - 2 * carton_t) + exploded_cursor = 0.0 + base_wall = ( + max(carton_t, z - carton_t) + if style_code == "rsc_center_open" + else min(z * 0.45, max(35.0, buffer_mm + 10.0)) + ) + add_node(package_index, "carton_base", "瓦楞纸箱下托", {"x": x, "y": y, "z": carton_t}, {"x": 0, "y": 0, "z": 0}, exploded_cursor, "#A9784A", carton_material_code) + exploded_cursor += carton_t + 30.0 + for role, name, dims, pos in ( + ("carton_wall_front", "下托前围边", {"x": x, "y": carton_t, "z": base_wall}, {"x": 0, "y": 0, "z": carton_t}), + ("carton_wall_back", "下托后围边", {"x": x, "y": carton_t, "z": base_wall}, {"x": 0, "y": y - carton_t, "z": carton_t}), + ("carton_wall_left", "下托左围边", {"x": carton_t, "y": inner_y, "z": base_wall}, {"x": 0, "y": carton_t, "z": carton_t}), + ("carton_wall_right", "下托右围边", {"x": carton_t, "y": inner_y, "z": base_wall}, {"x": x - carton_t, "y": carton_t, "z": carton_t}), + ): + add_node(package_index, role, name, dims, pos, exploded_cursor, "#B88955", carton_material_code) + exploded_cursor += base_wall + 30.0 + cushion_t = 8.0 + add_node( + package_index, + "bottom_cushion", + "底部纸基/EPE缓冲层", + {"x": inner_x, "y": inner_y, "z": cushion_t}, + {"x": carton_t, "y": carton_t, "z": carton_t + 3.0}, + exploded_cursor, + "#E8EEF2", + "epe_roll", + ) + exploded_cursor += cushion_t + 35.0 + package_items = [ + item for item in packing_layout if int(item.get("package_index") or 1) == package_index + ] + grouped: dict[int, list[dict]] = {} + for item in package_items: + grouped.setdefault(int(item.get("layer_index") or 1), []).append(item) + for layer_number in sorted(grouped): + # A full-size pad after every physical board layer over-protects + # ordinary flat packs and materially double-counts corrugated + # board. Group up to three compatible board layers between pads; + # fragile/special faces are handled by the scenario protection. + if layer_number > 1 and (layer_number - 1) % 3 == 0: + separator_z = min( + float((item.get("position_mm") or {}).get("z") or 0.0) for item in grouped[layer_number] + ) - 1.5 + add_node( + package_index, + f"separator_{layer_number}", + f"第{layer_number - 1}/{layer_number}层隔板", + {"x": inner_x, "y": inner_y, "z": 3.0}, + {"x": carton_t, "y": carton_t, "z": max(carton_t, separator_z)}, + exploded_cursor, + "#F5F7F8", + "protective_sheet", + ) + exploded_cursor += 38.0 + layer_height = 1.0 + for item in grouped[layer_number]: + dims = item.get("dimensions_mm") or {} + packed = item.get("position_mm") or {} + layer_height = max(layer_height, float(dims.get("z") or 1.0)) + add_node( + package_index, + f"product_{layer_number}", + str(item.get("node_name") or "产品部件"), + dims, + packed, + exploded_cursor, + cls.COMPONENT_COLORS[(len(nodes) + layer_number) % len(cls.COMPONENT_COLORS)], + "product_part", + ) + exploded_cursor += layer_height + 38.0 + if x >= 220 and y >= 150: + add_node( + package_index, + "hardware_box", + "五金与说明书配件盒", + {"x": min(160.0, inner_x * 0.25), "y": min(100.0, inner_y * 0.25), "z": 30.0}, + {"x": carton_t + 10.0, "y": carton_t + 10.0, "z": max(carton_t + 12.0, z - buffer_mm - 35.0)}, + exploded_cursor, + "#D6B37C", + "hardware_bag", + ) + exploded_cursor += 65.0 + add_node( + package_index, + "top_cushion", + "顶部纸基/EPE缓冲层", + {"x": inner_x, "y": inner_y, "z": cushion_t}, + {"x": carton_t, "y": carton_t, "z": max(carton_t, z - carton_t - cushion_t)}, + exploded_cursor, + "#E8EEF2", + "epe_roll", + ) + exploded_cursor += cushion_t + 35.0 + top_name = "中开箱上摇盖" if style_code == "rsc_center_open" else ("天地盖上盖" if style_code == "telescopic_lid_base" else "围板天地盖上盖") + add_node( + package_index, + "carton_top", + top_name, + {"x": x, "y": y, "z": carton_t}, + {"x": 0, "y": 0, "z": z - carton_t}, + exploded_cursor, + "#A9784A", + carton_material_code, + ) + if style_code in {"telescopic_lid_base", "sleeve_lid_base"}: + lid_wall = min(z * 0.50, max(40.0, buffer_mm + 15.0)) + lid_z = max(carton_t, z - lid_wall) + for role, name, dims, pos in ( + ("lid_wall_front", "上盖前围边", {"x": x, "y": carton_t, "z": lid_wall}, {"x": 0, "y": 0, "z": lid_z}), + ("lid_wall_back", "上盖后围边", {"x": x, "y": carton_t, "z": lid_wall}, {"x": 0, "y": y - carton_t, "z": lid_z}), + ("lid_wall_left", "上盖左围边", {"x": carton_t, "y": inner_y, "z": lid_wall}, {"x": 0, "y": carton_t, "z": lid_z}), + ("lid_wall_right", "上盖右围边", {"x": carton_t, "y": inner_y, "z": lid_wall}, {"x": x - carton_t, "y": carton_t, "z": lid_z}), + ): + add_node(package_index, role, name, dims, pos, exploded_cursor, "#B88955", carton_material_code) + return nodes + + def _packaging_bom(self, analysis: dict, selections: dict) -> dict: + scenario_name = str(selections.get("shipping_scenario") or "电商快递") + scenario = dict(self.SHIPPING_SCENARIOS[scenario_name]) + shipment_mode = str(selections.get("shipment_mode") or "整装") + packaging_profile = str(selections.get("packaging_profile") or "常规防护包装") + if packaging_profile == "可回收平板包装": + shipment_mode = "拆装" + scenario["epe_factor"] = min(float(scenario["epe_factor"]), 0.25) + scenario["honeycomb_factor"] = max(float(scenario["honeycomb_factor"]), 0.85) + elif shipment_mode == "拆装" and self._is_panel_selection(selections): + # Flat panel packs need edge/corner isolation, not full-surface EPE on + # every face. The former 1.0× outer-surface rule materially overstated + # the company's board-furniture packaging benchmark. + scenario["epe_factor"] = min(float(scenario["epe_factor"]), 0.25) + style_selections = {**selections, "shipment_mode": shipment_mode} + box_style_code, box_style, box_style_is_recommended = self._packaging_box_style( + analysis, style_selections + ) + carton_grade_code, carton_grade, carton_grade_is_recommended = self._carton_grade( + analysis, style_selections + ) + carton_material_code = str(carton_grade["material_code"]) + overall = analysis.get("overall_dimensions_mm") or {} + components = analysis.get("components") or [] + total_units = sum(int(item.get("quantity") or 1) for item in components) + package_count = int(selections.get("package_count") or 0) + if package_count <= 0: + longest = max(float(overall.get("x") or 0), float(overall.get("y") or 0), float(overall.get("z") or 0)) + package_count = 2 if shipment_mode == "拆装" and longest > 1400 else 1 + package_count = max(1, min(20, package_count)) + buffer_mm = float(scenario["buffer_mm"]) + if shipment_mode == "拆装" and self._is_panel_selection(selections): + # Flat packs protect the stack perimeter and corners; they do not need + # the assembled-product clearance on all six sides. + buffer_mm = min(buffer_mm, 30.0) + packing_layout: list[dict] = [] + package_dimensions_list: list[dict] = [] + if shipment_mode == "整装": + package_count = 1 + package_dimensions = { + "x": float(overall.get("x") or 1) + 2 * buffer_mm, + "y": float(overall.get("y") or 1) + 2 * buffer_mm, + "z": float(overall.get("z") or 1) + 2 * buffer_mm, + } + package_dimensions_list = [{"package_index": 1, **package_dimensions}] + packing_layout.append( + { + "package_index": 1, + "node_name": "assembled_product", + "position_mm": {"x": buffer_mm, "y": buffer_mm, "z": buffer_mm}, + "dimensions_mm": {axis: float(overall.get(axis) or 1) for axis in ("x", "y", "z")}, + } + ) + else: + oriented: list[tuple[list[float], dict, int]] = [] + for item in components: + quantity = int(item.get("quantity") or 1) + dims = sorted((float(value) for value in (item.get("dimensions_mm") or {}).values()), reverse=True) + if len(dims) != 3: + continue + for instance_index in range(quantity): + oriented.append((dims, item, instance_index + 1)) + length = max((entry[0][0] for entry in oriented), default=float(overall.get("x") or 1)) + width = max((entry[0][1] for entry in oriented), default=float(overall.get("y") or 1)) + # First-fit shelf packing: parts that fit side-by-side share one physical + # layer. This prevents legs, paired doors and small fittings from each + # adding a full layer to package height. + layers: list[dict] = [] + for index, (dims, item, instance_index) in enumerate(sorted(oriented, key=lambda entry: entry[0][0] * entry[0][1], reverse=True)): + placed = None + for layer_index, layer in enumerate(layers): + for part_x, part_y in ((dims[0], dims[1]), (dims[1], dims[0])): + for shelf in layer["shelves"]: + if part_y <= shelf["height"] and shelf["used_x"] + part_x <= length: + placed = (layer_index, shelf["used_x"], shelf["y"], part_x, part_y) + shelf["used_x"] += part_x + break + if placed: + break + used_y = sum(float(shelf["height"]) for shelf in layer["shelves"]) + if used_y + part_y <= width: + layer["shelves"].append({"y": used_y, "height": part_y, "used_x": part_x}) + placed = (layer_index, 0.0, used_y, part_x, part_y) + break + if placed: + break + if not placed: + layer_index = len(layers) + layers.append({"height": dims[2], "shelves": [{"y": 0.0, "height": dims[1], "used_x": dims[0]}], "parts": []}) + placed = (layer_index, 0.0, 0.0, dims[0], dims[1]) + layer = layers[placed[0]] + layer["height"] = max(float(layer["height"]), dims[2]) + layer["parts"].append( + { + "node_name": f"{item.get('part_id')}:{instance_index}", + "x": placed[1], "y": placed[2], + "dimensions_mm": {"x": placed[3], "y": placed[4], "z": dims[2]}, + } + ) + package_count = min(package_count, max(1, len(layers))) + package_layers: list[list[dict]] = [[] for _ in range(package_count)] + package_loads = [0.0 for _ in range(package_count)] + for layer in sorted(layers, key=lambda value: float(value["height"]), reverse=True): + package_index = min(range(package_count), key=lambda value: package_loads[value]) + package_layers[package_index].append(layer) + package_loads[package_index] += float(layer["height"]) + (3.0 if package_layers[package_index][:-1] else 0.0) + for package_index, assigned_layers in enumerate(package_layers): + stack_height = buffer_mm + used_length = used_width = 1.0 + for layer_number, layer in enumerate(assigned_layers): + if layer_number: + stack_height += 3.0 + for part in layer["parts"]: + dims = part["dimensions_mm"] + used_length = max(used_length, float(part["x"]) + float(dims["x"])) + used_width = max(used_width, float(part["y"]) + float(dims["y"])) + packing_layout.append( + { + "package_index": package_index + 1, + "node_name": part["node_name"], + "layer_index": layer_number + 1, + "position_mm": { + "x": buffer_mm + float(part["x"]), + "y": buffer_mm + float(part["y"]), + "z": stack_height, + }, + "dimensions_mm": dims, + } + ) + stack_height += float(layer["height"]) + package_dimensions_list.append( + { + "package_index": package_index + 1, + "x": used_length + 2 * buffer_mm, + "y": used_width + 2 * buffer_mm, + "z": stack_height + buffer_mm, + } + ) + package_dimensions = { + axis: max((float(item[axis]) for item in package_dimensions_list), default=1.0) + for axis in ("x", "y", "z") + } + package_surface_total = sum(self._box_surface_area_m2(item) for item in package_dimensions_list) + carton_blank_area_total = sum( + self._carton_blank_area_m2(item, box_style_code) for item in package_dimensions_list + ) + packaging_layers = self._packaging_layer_nodes( + package_dimensions_list, packing_layout, box_style_code, buffer_mm, carton_material_code + ) + carton_outer_blank_area_total = carton_blank_area_total + internal_separator_area_total = sum( + float((item.get("dimensions_mm") or {}).get("x") or 0.0) + * float((item.get("dimensions_mm") or {}).get("y") or 0.0) + / 1_000_000 + for item in packaging_layers + if str(item.get("node_role") or "").startswith("separator_") + ) + version, prices = self._active_packaging_prices() + lines: list[dict] = [] + + def add(code: str, quantity: float, reason: str) -> None: + if quantity <= 0: + return + price = prices[code] + waste = float(price["waste_rate"]) + cost = float(quantity) * float(price["unit_price"]) * (1 + waste) + lines.append( + { + "material_code": code, + "material_name": price["material_name"], + "unit": price["unit"], + "quantity": round(float(quantity), 4), + "unit_price": float(price["unit_price"]), + "waste_rate": waste, + "cost": cost, + "reason": reason, + } + ) + + add( + carton_material_code, + carton_blank_area_total, + f"按{carton_grade['label']}、{box_style['label']}({box_style['fefco_family']})和各包裹内尺寸计算外箱展开面积" + f"{carton_outer_blank_area_total:.3f}㎡;单价为¥{float(prices[carton_material_code]['unit_price']):.2f}/㎡,包材损耗2%只计算一次;" + "正式生产以纸箱厂打样刀模和性能规格替换", + ) + epe_code = "epe_roll" if (self._is_panel_selection(selections) or self._is_metal_selection(selections)) and packaging_profile != "高防护包装" else "epe_sheet" + metal_wrap_area = 0.0 + if self._is_metal_selection(selections): + shape = str(selections.get("metal_profile_shape") or "") + if shape == "round_tube": + perimeter_m = math.pi * float(selections.get("metal_profile_diameter_mm") or 0) / 1000 + else: + perimeter_m = 2 * ( + float(selections.get("metal_profile_width_mm") or 0) + + float(selections.get("metal_profile_height_mm") or 0) + ) / 1000 + metal_length_m = sum( + max(float(value or 0) for value in (item.get("dimensions_mm") or {}).values()) + * int(item.get("quantity") or 1) / 1000 + for item in self._metal_components(analysis, selections) + if (item.get("dimensions_mm") or {}) + ) + metal_wrap_area = perimeter_m * metal_length_m * 1.10 + epe_quantity = max( + package_surface_total * float(scenario["epe_factor"]), + internal_separator_area_total, + ) + metal_wrap_area + add( + epe_code, + epe_quantity, + "按各包裹真实表面积、运输场景和CAD层间隔板实际面积配置EPE防护" + + (f";另按金属外露面{metal_wrap_area:.3f}㎡逐件防刮包裹" if metal_wrap_area > 0 else ""), + ) + add("corner_guard", 8 * package_count, "每个包裹八个护角") + add("pe_bag", package_count, "每个包裹一个防尘袋") + loop_m = sum( + 2 * (float(item["x"]) + float(item["z"]) + float(item["y"]) + float(item["z"])) / 1000 + for item in package_dimensions_list + ) + add("tape", loop_m * 1.5, "封箱及局部加固") + add("strap", loop_m, "长、宽方向各一道打包带") + add("desiccant", int(scenario["desiccant_each"]) * package_count, "按运输场景配置") + add("hardware_bag", 1, "五金与安装配件集中包装") + add("label_manual", 1, "标签、说明书与装箱清单") + add("honeycomb", package_surface_total * float(scenario["honeycomb_factor"]), "按运输场景加固易受压面") + if selections.get("use_pallet"): + add("pallet", package_count, "用户确认使用托盘") + return { + "price_version": version, + "shipment_mode": shipment_mode, + "shipping_scenario": scenario_name, + "packaging_profile": packaging_profile, + "packaging_box_style": box_style_code, + "packaging_box_style_label": box_style["label"], + "packaging_box_style_standard": box_style["fefco_family"], + "packaging_box_style_description": box_style["description"], + "packaging_box_style_is_recommended": box_style_is_recommended, + "carton_grade": carton_grade_code, + "carton_grade_label": carton_grade["label"], + "carton_grade_description": carton_grade["description"], + "carton_grade_source": carton_grade["source_note"], + "carton_cost_multiplier": float(carton_grade["cost_multiplier"]), + "carton_grade_specification_status": carton_grade["specification_status"], + "carton_grade_is_recommended": carton_grade_is_recommended, + "carton_unit_price_per_m2": float(prices[carton_material_code]["unit_price"]), + "carton_blank_area_m2": carton_blank_area_total, + "carton_outer_blank_area_m2": carton_outer_blank_area_total, + "internal_separator_area_m2": internal_separator_area_total, + "packaging_minutes_per_package": ( + (6.0 if shipment_mode == "拆装" and self._is_panel_selection(selections) else 10.0) + + float(box_style["labor_delta_minutes"]) + ), + "package_count": package_count, + "package_dimensions_mm": package_dimensions, + "package_dimensions": package_dimensions_list, + "total_packaging_volume_m3": sum( + float(item["x"]) * float(item["y"]) * float(item["z"]) for item in package_dimensions_list + ) / 1_000_000_000, + "volume_weight_kg": sum( + float(item["x"]) * float(item["y"]) * float(item["z"]) for item in package_dimensions_list + ) / 6_000_000, + "packing_layout": packing_layout, + "packaging_layers": packaging_layers, + "packaging_cad_default_state": "exploded", + "calculation_method": ( + f"CAD成品外包围+{box_style['label']}结构+逐层防护" + if shipment_mode == "整装" + else f"CAD板件二维分层排布+隔层+{box_style['label']}结构" + ), + "lines": lines, + "material_cost": sum(float(line["cost"]) for line in lines), + "confidence": "configured", + "price_source_note": version["source_note"], + "public_design_basis": ( + "参考IKEA公开的平板化、减小运输体积、可回收纸基包装及森林材料可追溯原则;" + "不是IKEA供应商认证,也不代替其供应商门户中的项目专用规范" + if packaging_profile == "可回收平板包装" + else ( + "按IKEA公开的紧凑平板化与纸基材料原则设计;箱型采用FEFCO公开分类," + "运输验证按ISTA家具/配送场景选择。IKEA项目专用供应商包装参数未公开," + "不能宣称为IKEA内部规范或认证" + ) + ), + "material_accounting": [ + { + "material_code": line["material_code"], + "material_name": line["material_name"], + "quantity": line["quantity"], + "unit": line["unit"], + } + for line in lines + ], + } + + def _process_costs( + self, + analysis: dict, + selections: dict, + *, + coating_area: float, + finish: dict, + packaging: dict, + edge_length_m: float = 0.0, + panel_sheet_count: float = 0.0, + panel_mode: bool = False, + ) -> dict: + standard_version, standards = self._active_process_standards() + labor_version, labor_rates = self._active_labor_rates() + batch_qty = max(1, int(selections.get("batch_qty") or 1)) + units = sum(int(item.get("quantity") or 1) for item in analysis.get("components") or []) + door_count = self._door_panel_count(analysis.get("components") or []) + drawer_count = self._drawer_front_count(analysis.get("components") or []) + hardware_counts = self._hardware_counts( + analysis, selections, door_count=door_count, drawer_count=drawer_count + ) + holes = slots = joints = 0 + slot_length_m = profile_m = carving_area_m2 = carving_path_m = 0.0 + for item in analysis.get("components") or []: + quantity = int(item.get("quantity") or 1) + name = str(item.get("name") or "") + machining = item.get("machining") or {} + holes += int(machining.get("holes") or 0) * quantity + slots += int(machining.get("slots") or 0) * quantity + slot_length_m += float(machining.get("slot_length_m") or 0.0) * quantity + joints += int(machining.get("mortise_tenon") or 0) * quantity + profile_m += float(machining.get("edge_profile_m") or 0) * quantity + carving_area_m2 += float(machining.get("carving_area_m2") or 0) * quantity + carving_path_m += float(machining.get("carving_path_m") or 0) * quantity + if selections.get("carving_confirmed") is False: + carving_area_m2 = 0.0 + carving_path_m = 0.0 + lines: list[dict] = [] + by_code = {str(item["process_code"]): item for item in standards} + + def append_line(standard: dict, run_minutes: float, driver: str) -> None: + setup_per_product = float(standard["setup_minutes"]) / batch_qty + standard_minutes = setup_per_product + max(0.0, float(run_minutes)) + # The active rate is already an effective-production-hour rate. Applying + # the nominal efficiency again would inflate labor a second time. + paid_minutes = standard_minutes + rate = labor_rates[str(standard["worker_code"])] + cost = paid_minutes / 60 * float(rate["hourly_rate"]) + lines.append( + { + "cost_line_id": f"LAB-{standard['process_code']}", + "cost_category": "labor", + "summable": True, + "process_code": standard["process_code"], + "process_name": standard["process_name"], + "worker_code": standard["worker_code"], + "worker_name": rate["worker_name"], + "driver": driver, + "setup_minutes_total": float(standard["setup_minutes"]), + "setup_minutes_per_product": setup_per_product, + "run_minutes_per_product": float(run_minutes), + "standard_minutes_per_product": standard_minutes, + "efficiency": 1.0, + "efficiency_basis": "费率已含正常效率,不再二次折算", + "paid_hours_per_product": paid_minutes / 60, + "hourly_rate": float(rate["hourly_rate"]), + "cost_per_product": cost, + } + ) + + for standard in standards: + code = str(standard["process_code"]) + if code in {"finishing", "packaging", "surface_press", "intercoat_sanding"}: + continue + if panel_mode and code == "thicknessing": + continue + if not panel_mode and code == "panel_edge_banding": + continue + if panel_mode and code == "sanding" and int(finish.get("coats") or 0) == 0: + continue + if panel_mode and code == "material_cutting": + # 板式开料由实际采购板张驱动。8分钟/张对应单班约60张, + # 不再对每个拆件机械叠加固定开料分钟。 + run = 8.0 * max(0.0, panel_sheet_count) + append_line(standard, run, f"采购板张折算{panel_sheet_count:.3f}张,按8分钟/张开料") + continue + if code == "cnc_joinery": + production_joinery = analysis.get("production_joinery") or {} + if production_joinery.get("applicable"): + # Confirmat/dowel bores and straight back/drawer grooves run + # as a nested batch program on a six-sided drill/CNC. The + # former per-hole manual fixture time overstated this route. + run = ( + 0.12 * holes + + 0.45 * slots + + 0.35 * slot_length_m + + float(standard["joint_minutes"]) * joints + + float(standard["profile_minutes_m"]) * profile_m + ) + routing_basis = "量产六面钻/数控成组孔槽节拍" + else: + run = ( + float(standard["hole_minutes"]) * holes + + float(standard["slot_minutes"]) * slots + + float(standard["joint_minutes"]) * joints + + float(standard["profile_minutes_m"]) * slot_length_m + + float(standard["profile_minutes_m"]) * profile_m + ) + routing_basis = "单件特殊孔槽标准工时" + if run <= 0: + continue + append_line( + standard, + run, + f"孔{holes}个,槽{slots}个(槽总长{slot_length_m:.3f}m),榫卯{joints}个,异形线{profile_m:.3f}m;{routing_basis},仅按CAD实际特征计费", + ) + continue + if code == "carving": + run = float(standard["area_minutes_m2"]) * carving_area_m2 + float(standard["profile_minutes_m"]) * carving_path_m + if run <= 0: + continue + append_line( + standard, + run, + f"雕刻面积{carving_area_m2:.3f}㎡、路径{carving_path_m:.3f}m;必须有图像/BOM证据或用户确认", + ) + continue + if code == "hardware_installation": + run = ( + 0.80 * hardware_counts["hinge"] + + 1.20 * hardware_counts["drawer_slide_pair"] + + 0.35 * hardware_counts["handle"] + + 0.08 * hardware_counts["connector"] + + 1.50 * hardware_counts["sliding_track_set"] + ) + if run <= 0: + continue + append_line( + standard, + run, + "五金定位安装:" + f"铰链{hardware_counts['hinge']}个、抽屉导轨{hardware_counts['drawer_slide_pair']}副、" + f"拉手{hardware_counts['handle']}个、连接件{hardware_counts['connector']}个、" + f"移门轨道滑轮{hardware_counts['sliding_track_set']}套;拆装仅免最终组装,不免工厂钻孔定位、预装核对和配件分包", + ) + continue + if code == "assembly" and str(selections.get("shipment_mode") or "") == "拆装": + run = 0.25 * units + 1.0 * door_count + 1.0 * joints + append_line( + standard, + run, + f"拆装发货:{units}件结构用工装/首件抽检形位,{door_count}扇门功能抽检;" + "量产单件不计完整整装,也不重复包含五金定位安装", + ) + continue + if panel_mode and code == "quality": + run = 0.20 * units + 0.50 * door_count + append_line(standard, run, f"板式批量终检:{units}件尺寸抽检,{door_count}扇门功能检查") + continue + if code == "panel_edge_banding": + run = float(standard["profile_minutes_m"]) * edge_length_m + if run <= 0: + continue + scope_labels = {"all_edges": "全部边", "exposed_edges": "外露边", "custom": "自定义"} + append_line(standard, run, f"{scope_labels.get(str(selections.get('edge_scope')), '已确认')}封边总长{edge_length_m:.3f}m") + continue + run = ( + float(standard["unit_minutes"]) * units + + float(standard["area_minutes_m2"]) * coating_area + + float(standard["door_minutes"]) * door_count + + float(standard["hole_minutes"]) * holes + + float(standard["slot_minutes"]) * slots + + float(standard["joint_minutes"]) * joints + + float(standard["profile_minutes_m"]) * profile_m + ) + if run <= 0: + continue + append_line( + standard, + run, + f"部件{units}件,涂装面积{coating_area:.3f}㎡,门{door_count}扇,孔{holes}个,槽{slots}个,榫卯{joints}个", + ) + surface_run = coating_area * float(finish.get("surface_labor_h_m2") or 0.0) * 60 + if surface_run > 0: + append_line( + by_code["surface_press"], + surface_run, + f"饰面展开面积{coating_area:.3f}㎡;只计涂胶、压贴与修边,材料另列", + ) + finish_run = coating_area * float(finish.get("coating_labor_h_m2") or 0.0) * 60 + if finish_run > 0: + finish_standard = dict(by_code["finishing"]) + finish_driver = f"涂装面积{coating_area:.3f}㎡,{int(finish['coats'])}遍;只计调漆、喷涂/擦涂,不含白坯、层间打磨和自然干燥" + append_line(finish_standard, finish_run, finish_driver) + intercoat_run = coating_area * float(finish.get("intercoat_sanding_h_m2") or 0.0) * max(0, int(finish.get("coats") or 0) - 1) * 60 + if intercoat_run > 0: + append_line( + by_code["intercoat_sanding"], + intercoat_run, + f"涂装面积{coating_area:.3f}㎡,{max(0, int(finish.get('coats') or 0) - 1)}次层间打磨;与白坯打磨互斥分列", + ) + packaging_standard = by_code["packaging"] + packaging_minutes_each = float( + packaging.get("packaging_minutes_per_package") + or packaging_standard["unit_minutes"] + ) + packaging_run = packaging_minutes_each * int(packaging["package_count"]) + append_line( + packaging_standard, + packaging_run, + f"{packaging['package_count']}个{packaging.get('packaging_box_style_label', '纸箱')}包裹," + f"每包{packaging_minutes_each:g}分钟,按装袋、护角、逐层装箱、封箱和打带动作计时;包材种类不重复加时", + ) + finish_codes = {"finishing", "surface_press", "intercoat_sanding"} + wood_lines = [line for line in lines if line["process_code"] not in finish_codes | {"packaging"}] + finish_lines = [line for line in lines if line["process_code"] in finish_codes] + packaging_line = next(line for line in lines if line["process_code"] == "packaging") + return { + "standard_version": standard_version, + "labor_rate_version": labor_version, + "batch_qty": batch_qty, + "efficiency": 1.0, + "nominal_efficiency_reference": self.production_efficiency, + "efficiency_applied_again": False, + "lines": lines, + "wood_hours": sum(float(line["paid_hours_per_product"]) for line in wood_lines), + "finish_hours": sum(float(line["paid_hours_per_product"]) for line in finish_lines), + "packaging_hours": float(packaging_line["paid_hours_per_product"]), + "wood_labor": sum(float(line["cost_per_product"]) for line in wood_lines), + "finish_labor": sum(float(line["cost_per_product"]) for line in finish_lines), + "packaging_labor": float(packaging_line["cost_per_product"]), + "total_labor": sum(float(line["cost_per_product"]) for line in lines), + } + + def _manufacturing_resource_costs(self, processes: dict) -> dict: + process_lines = processes.get("lines") or [] + machine_lines: list[dict] = [] + for line in process_lines: + process_code = str(line.get("process_code") or "") + hourly = float(self.MACHINE_HOURLY_COSTS.get(process_code, 0.0)) + hours = float(line.get("standard_minutes_per_product") or 0.0) / 60 + cost = hours * hourly + if cost <= 0: + continue + machine_lines.append( + { + "cost_line_id": f"MFG-MACHINE-{process_code}", + "cost_category": "manufacturing_overhead", + "resource_pool": "机器折旧与能耗", + "driver": f"{process_code}设备占用{hours:.3f}小时×¥{hourly:.2f}/小时", + "cost": cost, + "summable": True, + } + ) + occupied_hours = sum(float(line.get("standard_minutes_per_product") or 0.0) for line in process_lines) / 60 + factory_cost = occupied_hours * self.FACTORY_OCCUPANCY_PER_HOUR + management_cost = float(processes.get("total_labor") or 0.0) * self.WORKSHOP_MANAGEMENT_LABOR_RATE + lines = machine_lines + [ + { + "cost_line_id": "MFG-FACTORY-OCCUPANCY", + "cost_category": "manufacturing_overhead", + "resource_pool": "厂房费用", + "driver": f"工位占用{occupied_hours:.3f}小时×¥{self.FACTORY_OCCUPANCY_PER_HOUR:.2f}/小时", + "cost": factory_cost, + "summable": True, + }, + { + "cost_line_id": "MFG-WORKSHOP-MANAGEMENT", + "cost_category": "manufacturing_overhead", + "resource_pool": "车间管理", + "driver": f"只按直接人工¥{float(processes.get('total_labor') or 0):.2f}×{self.WORKSHOP_MANAGEMENT_LABOR_RATE:.0%}", + "cost": management_cost, + "summable": True, + }, + ] + return { + "version": self.OVERHEAD_RESOURCE_VERSION, + "lines": lines, + "total": sum(float(line["cost"]) for line in lines), + "rule": "只按设备占用、工位占用和直接人工管理资源计费;不对木材、油漆、五金或包材加比例", + } + + def _metal_costing(self, analysis: dict, selections: dict) -> dict: + if not self._is_metal_selection(selections): + return {"material_lines": [], "process_lines": [], "material_low": 0.0, "material_mid": 0.0, "material_high": 0.0, "process_low": 0.0, "process_mid": 0.0, "process_high": 0.0} + price_version, prices, _ = self._active_metal_prices() + process_version, process_prices = self._active_metal_process_prices() + company_version, company_rows = self._active_company_metal_benchmarks() + material_code = str(selections.get("metal_material_code") or "") + price = prices.get(material_code) + if not price: + raise SolidWoodCostError(f"金属材料价格不存在:{material_code}") + shape = str(selections.get("metal_profile_shape") or "") + wall = float(selections.get("metal_wall_thickness_mm") or 0) + width = float(selections.get("metal_profile_width_mm") or 0) + height = float(selections.get("metal_profile_height_mm") or width) + diameter = float(selections.get("metal_profile_diameter_mm") or 0) + kg_m = self._metal_profile_kg_m( + shape, + width_mm=width, + height_mm=height, + diameter_mm=diameter, + wall_thickness_mm=wall, + density_kg_m3=float(price["density_kg_m3"]), + ) + if kg_m <= 0: + raise SolidWoodCostError("金属型材截面或壁厚无效") + if shape in {"square_tube", "rect_tube"} and wall * 2 >= min(width, height): + raise SolidWoodCostError("金属管壁厚必须小于截面短边的一半") + if shape == "round_tube" and wall * 2 >= diameter: + raise SolidWoodCostError("圆管壁厚必须小于直径的一半") + company_match = self._matching_company_metal_benchmark( + company_rows, + material_code=material_code, + shape=shape, + width_mm=width, + height_mm=height, + diameter_mm=diameter, + wall_thickness_mm=wall, + ) + company_applied = bool(company_match and str(company_match.get("confidence") or "") == "high") + effective_loss_rate = float( + company_match["waste_rate"] if company_applied else price["waste_rate"] + ) + components = self._metal_components(analysis, selections) + if not components: + raise SolidWoodCostError("没有确认到可计价的金属部件") + material_lines: list[dict] = [] + total_length = total_net_weight = total_purchase_weight = total_area = 0.0 + total_low = total_mid = total_high = 0.0 + public_reference_mid = 0.0 + cut_count = 0 + for item in components: + dims = item.get("dimensions_mm") or {} + quantity = max(1, int(item.get("quantity") or 1)) + length_m = max(float(dims.get("x") or 0), float(dims.get("y") or 0), float(dims.get("z") or 0)) * quantity / 1000 + if length_m <= 0: + continue + net_weight = length_m * kg_m + purchase_weight = net_weight * (1 + effective_loss_rate) + public_purchase_weight = net_weight * (1 + float(price["waste_rate"])) + if shape == "round_tube": + perimeter_m = math.pi * diameter / 1000 + else: + perimeter_m = 2 * (width + height) / 1000 + coating_area = perimeter_m * length_m + public_mid = public_purchase_weight * float(price["price_mid_kg"]) + public_reference_mid += public_mid + if company_applied: + purchase_length = length_m * (1 + effective_loss_rate) + low = purchase_length * float(company_match["price_low_m"]) + mid = purchase_length * float(company_match["price_mid_m"]) + high = purchase_length * float(company_match["price_high_m"]) + pricing_basis = "company_price_per_meter" + unit_price_mid = float(company_match["price_mid_m"]) + unit = "m" + loss_rule = f"型材长度×(1+{effective_loss_rate:.0%}公司损耗)×公司同规格每米价" + source_note = str(company_match["source_note"]) + else: + low = purchase_weight * float(price["price_low_kg"]) + mid = purchase_weight * float(price["price_mid_kg"]) + high = purchase_weight * float(price["price_high_kg"]) + pricing_basis = "public_price_per_kg" + unit_price_mid = float(price["price_mid_kg"]) + unit = "kg" + loss_rule = f"型材长度×{kg_m:.4f}kg/m×(1+{float(price['waste_rate']):.0%}采购损耗)" + source_note = str(price["source_note"]) + material_lines.append( + { + "cost_line_id": f"MAT-METAL-{item.get('part_id')}", + "part_id": item.get("part_id"), + "name": item.get("name"), + "quantity": quantity, + "dimensions_mm": dims, + "species": price["material_name"], + "material_family": "metal", + "metal_material_code": material_code, + "metal_profile": selections.get("metal_profile_display"), + "wall_thickness_mm": wall, + "theoretical_kg_m": kg_m, + "metal_length_m": length_m, + "net_weight_kg": net_weight, + "purchase_weight_kg": purchase_weight, + "net_volume_m3": 0.0, + "purchase_volume_m3": 0.0, + "loss_rule": loss_rule, + "pricing_basis": pricing_basis, + "unit_price_mid": unit_price_mid, + "unit": unit, + "cost_low": low, + "cost_mid": mid, + "cost_high": high, + "public_reference_cost_mid": public_mid, + "coating_area_m2": coating_area, + "source_note": source_note, + } + ) + total_length += length_m + total_net_weight += net_weight + total_purchase_weight += purchase_weight + total_area += coating_area + total_low += low + total_mid += mid + total_high += high + cut_count += quantity * 2 + if not material_lines: + raise SolidWoodCostError("金属部件缺少可用长度尺寸") + batch_qty = max(1, int(selections.get("batch_qty") or 1)) + process_lines: list[dict] = [] + + def add_process(code: str, driver_quantity: float, driver: str) -> None: + if driver_quantity <= 0: + return + row = process_prices.get(code) + if not row: + raise SolidWoodCostError(f"金属工艺价格不存在:{code}") + allowed = str(row.get("allowed_materials") or "*") + if allowed != "*" and material_code not in allowed.split(","): + raise SolidWoodCostError(f"{row['process_name']}不适用于{price['material_name']}") + setup = float(row["setup_cost_batch"]) / batch_qty + low = setup + driver_quantity * float(row["price_low"]) + mid = setup + driver_quantity * float(row["price_mid"]) + high = setup + driver_quantity * float(row["price_high"]) + process_lines.append( + { + "cost_line_id": f"METAL-PROC-{code.upper()}", + "cost_category": "metal_process", + "process_code": code, + "process_name": row["process_name"], + "driver": driver, + "driver_quantity": driver_quantity, + "driver_unit": row["driver_unit"], + "setup_cost_batch": float(row["setup_cost_batch"]), + "setup_cost_per_product": setup, + "cost_low": low, + "cost_mid": mid, + "cost_high": high, + "source_note": row["source_note"], + "summable": True, + } + ) + + add_process("tube_cut", float(cut_count), f"金属型材{cut_count}个实际切口;按BOM件数×2,不按部件成本加比例") + bends = sum(int((item.get("machining") or {}).get("bends") or 0) * int(item.get("quantity") or 1) for item in components) + laser_m = sum(float((item.get("machining") or {}).get("laser_cut_m") or 0) * int(item.get("quantity") or 1) for item in components) + add_process("bend", float(bends), f"BOM/图片明确折弯{bends}道;零折弯不计费") + add_process("laser_cut", laser_m, f"BOM/图片明确激光切割路径{laser_m:.3f}m;零路径不计费") + joining = str(selections.get("metal_joining") or "") + joint_count = int(selections.get("metal_joint_count") or 0) + if shape == "round_tube": + weld_per_joint = math.pi * diameter / 1000 + else: + weld_per_joint = 2 * (width + height) / 1000 + weld_coverage = 0.0 if joining == "bolted" else min(1.0, max(0.05, float(selections.get("metal_weld_coverage") or 0.35))) + grind_coverage = 0.0 if joining == "bolted" else min(1.0, max(0.0, float(selections.get("metal_grind_coverage") or 0.5))) + nominal_weld_length = joint_count * weld_per_joint if joining != "bolted" else 0.0 + weld_length = nominal_weld_length * weld_coverage + grind_length = weld_length * grind_coverage + if joining == "bolted": + add_process("metal_fastener", float(max(0, joint_count)), f"螺栓拆装接头{joint_count}个;焊缝为0,不产生焊接和焊缝打磨费") + else: + add_process( + joining, + weld_length, + f"{joint_count}个接头×截面周长{weld_per_joint:.3f}m×焊缝覆盖{weld_coverage:.0%}={weld_length:.3f}m实际焊缝", + ) + add_process( + "weld_grind", + grind_length, + f"实际焊缝{weld_length:.3f}m×外观打磨覆盖{grind_coverage:.0%}={grind_length:.3f}m", + ) + metal_finish = str(selections.get("metal_finish") or "raw") + if metal_finish == "phosphating_powder": + add_process("powder_coat_company", total_area, f"公司综合喷塑已含除油磷化;按金属型材外露面积{total_area:.3f}㎡只计一次") + elif metal_finish != "raw": + driver = total_purchase_weight if metal_finish == "aluminum_anodize" else total_area + unit_label = "kg" if metal_finish == "aluminum_anodize" else "㎡" + add_process(metal_finish, driver, f"金属处理量{driver:.3f}{unit_label};不按产品总成本加比例") + process_low = sum(float(line["cost_low"]) for line in process_lines) + process_mid = sum(float(line["cost_mid"]) for line in process_lines) + process_high = sum(float(line["cost_high"]) for line in process_lines) + company_material_mid = total_mid if company_applied else 0.0 + deviation_rate = ( + (company_material_mid - public_reference_mid) / public_reference_mid + if company_applied and public_reference_mid > 0 else None + ) + company_benchmark = { + "version": company_version.get("version_label") if company_version else None, + "source": company_version.get("source_table") if company_version else None, + "matched": bool(company_match), + "applied": company_applied, + "confidence": str((company_match or {}).get("confidence") or "none"), + "sample_count": int((company_match or {}).get("sample_count") or 0), + "sku_count": int((company_match or {}).get("sku_count") or 0), + "latest_at": (company_match or {}).get("latest_at"), + "company_price_mid_m": float((company_match or {}).get("price_mid_m") or 0), + "company_cost_mid": company_material_mid, + "public_reference_cost_mid": public_reference_mid, + "deviation_rate": deviation_rate, + "rule": "同材质、同截面、同壁厚且高置信度时优先使用公司每米价;公司价与公开公斤价只做二选一,不相加。低置信度或无同规格样本时仅提示偏差并使用公开价", + } + return { + "price_version": price_version, + "process_version": process_version, + "material_code": material_code, + "material_name": price["material_name"], + "profile": selections.get("metal_profile_display"), + "wall_thickness_mm": wall, + "theoretical_kg_m": kg_m, + "total_length_m": total_length, + "net_weight_kg": total_net_weight, + "purchase_weight_kg": total_purchase_weight, + "coating_area_m2": total_area, + "weld_length_m": weld_length, + "nominal_full_perimeter_weld_length_m": nominal_weld_length, + "weld_coverage": weld_coverage, + "grind_length_m": grind_length, + "grind_coverage": grind_coverage, + "material_lines": material_lines, + "process_lines": process_lines, + "material_low": total_low, + "material_mid": total_mid, + "material_high": total_high, + "process_low": process_low, + "process_mid": process_mid, + "process_high": process_high, + "company_benchmark": company_benchmark, + "company_process_benchmark": { + "powder_coat_sample_count": 4400, + "company_mid_per_m2": 10.0, + "public_market_reference_per_m2": 15.0, + "welding_company_separate_line_available": False, + "rule": "喷塑采用公司综合单价且已含前处理;焊接因公司BOM未独立列项,继续按公开焊缝米价,不从人工+杂费再次叠加", + }, + "rule": "同规格高置信度公司钢管按每米价,其他型材按理论重量和公开公斤价,二者绝不相加;下料、折弯、焊接、打磨和表面处理仅按非零物理驱动计价,综合喷塑含前处理且只计一次", + } + + @staticmethod + def _cost_ledger( + material_lines: list[dict], + edge_costing: dict | None, + finish_material: float, + hardware_bom: dict, + process_costing: dict, + packaging_bom: dict, + manufacturing: dict, + metal_costing: dict | None = None, + ) -> dict: + lines: list[dict] = [] + for item in material_lines: + item.setdefault("cost_line_id", f"MAT-{item.get('part_id')}") + item.setdefault("cost_category", "material") + item.setdefault("summable", True) + lines.append( + {"cost_line_id": item["cost_line_id"], "cost_category": "material", "amount": float(item.get("cost_mid") or 0), "summable": True} + ) + if edge_costing: + edge_costing.setdefault("cost_line_id", "MAT-EDGE-BANDING") + edge_costing.setdefault("cost_category", "material") + edge_costing.setdefault("summable", True) + lines.append({"cost_line_id": edge_costing["cost_line_id"], "cost_category": "material", "amount": float(edge_costing.get("cost_mid") or 0), "summable": True}) + if finish_material > 0: + lines.append({"cost_line_id": "MAT-FINISH-SURFACE", "cost_category": "finish_material", "amount": finish_material, "summable": True}) + for index, item in enumerate(hardware_bom.get("lines") or [], 1): + item.setdefault("cost_line_id", f"HW-{index:03d}") + item.setdefault("cost_category", "hardware") + item.setdefault("summable", True) + lines.append({"cost_line_id": item["cost_line_id"], "cost_category": "hardware", "amount": float(item.get("cost") or 0), "summable": True}) + for item in process_costing.get("lines") or []: + lines.append({"cost_line_id": item["cost_line_id"], "cost_category": "labor", "amount": float(item.get("cost_per_product") or 0), "summable": True}) + for item in (metal_costing or {}).get("process_lines") or []: + lines.append({"cost_line_id": item["cost_line_id"], "cost_category": "metal_process", "amount": float(item.get("cost_mid") or 0), "summable": True}) + for index, item in enumerate(packaging_bom.get("lines") or [], 1): + item.setdefault("cost_line_id", f"PKG-{str(item.get('material_code') or index).upper()}") + item.setdefault("cost_category", "packaging_material") + item.setdefault("summable", True) + lines.append({"cost_line_id": item["cost_line_id"], "cost_category": "packaging_material", "amount": float(item.get("cost") or 0), "summable": True}) + for item in manufacturing.get("lines") or []: + lines.append({"cost_line_id": item["cost_line_id"], "cost_category": "manufacturing_overhead", "amount": float(item.get("cost") or 0), "summable": True}) + ids = [str(item["cost_line_id"]) for item in lines] + if len(ids) != len(set(ids)): + raise SolidWoodCostError("成本台账存在重复cost_line_id") + total = sum(float(item["amount"]) for item in lines if item.get("summable")) + return { + "schema": "dalongxia.product-cost-ledger.v1", + "lines": lines, + "subtotals": [ + {"cost_line_id": f"SUBTOTAL-{category.upper()}", "cost_category": category, "amount": sum(float(item["amount"]) for item in lines if item["cost_category"] == category), "summable": False} + for category in sorted({str(item["cost_category"]) for item in lines}) + ], + "total": total, + "rule": "只有summable=true的唯一成本行参与总计;小计仅展示,不再次求和", + } + + def _calculate_quote(self, project: dict) -> dict: + selections = project["selections"] + analysis = self._apply_production_joinery(project["analysis"], selections) + panel_mode = self._is_panel_selection(selections) + metal_mode = self._is_metal_selection(selections) + metal_costing = self._metal_costing(analysis, selections) if metal_mode else { + "material_lines": [], "process_lines": [], "material_low": 0.0, "material_mid": 0.0, + "material_high": 0.0, "process_low": 0.0, "process_mid": 0.0, "process_high": 0.0, + } + metal_part_ids = {str(item.get("part_id") or "") for item in metal_costing.get("material_lines") or []} + panel_code = str(selections.get("panel_code") or self._panel_code_from_text(str(selections.get("main_species") or ""))) + finish_name = str(selections.get("finish") or "无木器饰面") + finish_template = self._finish_profile(selections) + panel_price_version = None + material_lines: list[dict] = [] + total_low = total_mid = total_high = 0.0 + panel_sheet_count = 0.0 + coating_area = 0.0 + edge_length_m = 0.0 + door_count = self._door_panel_count(analysis.get("components") or []) + drawer_count = self._drawer_front_count(analysis.get("components") or []) + for item in analysis["components"]: + dims = item["dimensions_mm"] + quantity = int(item["quantity"]) + name = str(item["name"]) + if str(item.get("part_id") or "") in metal_part_ids: + continue + net_volume_per_piece, profile_face_area = self._component_net_geometry(item) + net_volume = net_volume_per_piece * quantity + if panel_mode: + ordered = sorted((float(dims["x"]), float(dims["y"]), float(dims["z"]))) + detected_thickness = ordered[0] + preserve_bom_thickness = any(token in name for token in ("背板", "抽屉底", "底衬", "内衬")) + target_thickness = detected_thickness + item_panel_code = panel_code + if selections.get("panel_thickness_mode") == "grouped": + if preserve_bom_thickness: + target_thickness = float(selections.get("panel_thin_thickness_mm") or 2.5) + item_panel_code = "BACKBOARD" + else: + target_thickness = float(selections.get("panel_main_thickness_mm") or detected_thickness) + elif selections.get("panel_thickness_mode") == "uniform": + target_thickness = float(selections.get("panel_thickness_mm") or detected_thickness) + elif preserve_bom_thickness and detected_thickness <= 6.0: + item_panel_code = "BACKBOARD" + price = self._panel_price_for(item_panel_code, target_thickness, str(selections.get("emission_grade") or "E0")) + panel_price_version = price["version"] + standard_thickness = float(price["thickness_mm"]) + # `_panel_price_for` already selects a priced standard board close to + # the requested thickness. A small mismatch such as a visually + # estimated 20 mm panel against the 18 mm price row is one sheet, not + # two laminated sheets. Only an approximately integral multiple of + # the selected standard thickness represents a real built-up panel. + layer_count = max(1, int(math.floor(target_thickness / standard_thickness + 0.5))) + face_area = profile_face_area * quantity + net_sheet_area = face_area * layer_count + sheet_area = float(price["standard_length_mm"]) * float(price["standard_width_mm"]) / 1_000_000 + procurement = self._panel_procurement_profile( + selections, + float(price["nesting_yield_rate"]), + item_panel_code, + ) + effective_yield = float(procurement["nesting_yield_rate"]) + procurement_factor = float(procurement["price_factor"]) + purchase_sheets = net_sheet_area / (sheet_area * effective_yield) + panel_sheet_count += purchase_sheets + low = purchase_sheets * float(price["price_low_per_sheet"]) * procurement_factor + mid = purchase_sheets * float(price["price_mid_per_sheet"]) * procurement_factor + high = purchase_sheets * float(price["price_high_per_sheet"]) * procurement_factor + prefinished_premium = 0.0 + if finish_template.get("prefinished_panel"): + prefinished_premium = purchase_sheets * sheet_area * float(finish_template.get("panel_premium_m2") or 0.0) + low += prefinished_premium + mid += prefinished_premium + high += prefinished_premium + finish_area = face_area * 2.0 + item_edge_length, item_edge_rule = self._item_edge_length_m( + item, str(selections.get("edge_scope") or "all_edges") + ) + coating_area += finish_area + edge_length_m += item_edge_length + loss_rule = ( + f"展开面积×{layer_count}层÷{sheet_area:.4f}㎡/张÷{effective_yield:.0%}跨件排版利用率" + ) + material_lines.append( + { + "part_id": item["part_id"], "name": name, "quantity": quantity, + "dimensions_mm": dims, "species": price["material_name"], + "material_family": "engineered_panel", "panel_code": item_panel_code, + "emission_grade": price["emission_grade"], + "detected_thickness_mm": detected_thickness, + "quoted_thickness_mm": target_thickness, + "standard_thickness_mm": standard_thickness, + "thickness_deviation_mm": standard_thickness - target_thickness, + "layer_count": layer_count, + "net_volume_m3": net_volume, + "purchase_volume_m3": 0.0, + "net_area_m2": net_sheet_area, + "purchase_sheet_count": purchase_sheets, + "public_reference_unit_price_mid": price["price_mid_per_sheet"], + "procurement_price_factor": procurement_factor, + "procurement_basis": procurement["basis"], + "nesting_yield_rate": effective_yield, + "prefinished_surface_premium": prefinished_premium, + "thickness_rule": ( + "统一板厚已应用到全部板件" + if selections.get("panel_thickness_mode") == "uniform" + else ("特殊薄板保留BOM厚度" if preserve_bom_thickness else "按用户板厚口径") + ), + "loss_rule": loss_rule, + "unit_price_mid": float(price["price_mid_per_sheet"]) * procurement_factor, + "unit": "张", + "cost_low": low, "cost_mid": mid, "cost_high": high, + "coating_area_m2": finish_area, + "edge_length_m": item_edge_length, + "edge_length_rule": item_edge_rule, + "source_note": price["source_note"], + } + ) + else: + area = item.get("coating_area_m2") or ( + 2 * (dims["x"] * dims["y"] + dims["x"] * dims["z"] + dims["y"] * dims["z"]) * quantity / 1_000_000 + ) + finish_area = float(area) * 0.90 + coating_area += finish_area + species = self._selected_species_for_component(item, selections) + price = self._price_for(species) + if "加工料" in price["species_name"]: + purchase_volume = net_volume * 1.15 + loss_rule = "净体积×1.15" + else: + purchase_volume = net_volume / float(price["yield_rate"]) + loss_rule = f"净体积÷{float(price['yield_rate']):.0%}出材率" + low = purchase_volume * float(price["cubic_low"]) + mid = purchase_volume * float(price["cubic_mid"]) + high = purchase_volume * float(price["cubic_high"]) + material_lines.append( + { + "part_id": item["part_id"], "name": name, "quantity": quantity, + "dimensions_mm": dims, "species": price["species_name"], + "material_family": "solid_wood", + "net_volume_m3": net_volume, "purchase_volume_m3": purchase_volume, + "loss_rule": loss_rule, "unit_price_mid": price["cubic_mid"], "unit": "m³", + "cost_low": low, "cost_mid": mid, "cost_high": high, + "coating_area_m2": finish_area, + } + ) + material_lines[-1]["machining"] = dict(item.get("machining") or {}) + material_lines[-1]["manufacturing_features"] = json.loads( + json.dumps(item.get("manufacturing_features") or {"holes": [], "grooves": []}, ensure_ascii=False) + ) + total_low += low + total_mid += mid + total_high += high + wood_material_low, wood_material_mid, wood_material_high = total_low, total_mid, total_high + material_lines.extend(metal_costing.get("material_lines") or []) + total_low += float(metal_costing.get("material_low") or 0) + total_mid += float(metal_costing.get("material_mid") or 0) + total_high += float(metal_costing.get("material_high") or 0) + if panel_mode and selections.get("edge_scope") == "custom": + edge_length_m = float(selections.get("custom_edge_length_m") or 0.0) + edge_cost_low = edge_cost_mid = edge_cost_high = 0.0 + edge_costing = None + if panel_mode: + edge_price = self._edge_price_for(str(selections.get("edge_code") or "PVC_1")) + edge_factor = 1 + float(edge_price["waste_rate"]) + edge_cost_low = edge_length_m * float(edge_price["price_low"]) * edge_factor + edge_cost_mid = edge_length_m * float(edge_price["price_mid"]) * edge_factor + edge_cost_high = edge_length_m * float(edge_price["price_high"]) * edge_factor + total_low += edge_cost_low + total_mid += edge_cost_mid + total_high += edge_cost_high + edge_costing = { + "edge_code": edge_price["edge_code"], + "edge_name": edge_price["edge_name"], + "edge_length_m": edge_length_m, + "unit": edge_price["unit"], + "price_low": edge_price["price_low"], + "price_mid": edge_price["price_mid"], + "price_high": edge_price["price_high"], + "waste_rate": edge_price["waste_rate"], + "cost_low": edge_cost_low, + "cost_mid": edge_cost_mid, + "cost_high": edge_cost_high, + "price_version": edge_price["version"], + "includes_adhesive": bool(edge_price.get("includes_adhesive")), + } + finish = dict(finish_template) + env_prefix = f"DALONGXIA_FINISH_{finish_name}_PRICE_PER_L" + configured_finish_price = float(os.environ.get(env_prefix, str(finish["price_per_l"]))) + finish["price_per_l"] = max(0.0, configured_finish_price) + finish_liters = coating_area * finish["coats"] / finish["coverage_m2_l"] * (1 + finish["loss_rate"]) + surface_material = coating_area * float(finish.get("surface_material_m2") or 0.0) * (1 + float(finish["loss_rate"])) + finish_material = finish_liters * finish["price_per_l"] + surface_material + packaging = self._packaging_bom(analysis, selections) + wood_analysis = { + **analysis, + "components": [ + item for item in (analysis.get("components") or []) + if str(item.get("part_id") or "") not in metal_part_ids + ], + } + processes = self._process_costs( + wood_analysis, + selections, + coating_area=coating_area, + finish=finish, + packaging=packaging, + edge_length_m=edge_length_m, + panel_sheet_count=panel_sheet_count, + panel_mode=panel_mode, + ) + labor_cost = float(processes["total_labor"]) + wood_labor = float(processes["wood_labor"]) + finish_labor = float(processes["finish_labor"]) + packaging_labor = float(processes["packaging_labor"]) + history_benchmark = { + "status": "disabled_for_blind_estimation", + "confidence": "none", + "sample_count": 0, + "reason": "在线估价不读取公司同款SKU、同款BOM或成品成本;公司答案只在报价完成后由独立测试程序对照", + } + hardware_bom = self._hardware_costing( + analysis, + selections, + door_count=door_count, + drawer_count=drawer_count, + ) + hardware_formula_cost = float(hardware_bom["formula_cost"]) + hardware_cost = float(hardware_bom["cost"]) + hardware_history_reference = 0.0 + history_confidence = "none" + history_weight = 0.0 + packaging_cost = float(packaging["material_cost"]) + metal_process_mid = float(metal_costing.get("process_mid") or 0) + direct_mid = total_mid + metal_process_mid + finish_material + labor_cost + hardware_cost + packaging_cost + manufacturing = self._manufacturing_resource_costs(processes) + overhead = float(manufacturing["total"]) + total_cost = direct_mid + overhead + low_total = total_cost - total_mid - metal_process_mid + total_low + float(metal_costing.get("process_low") or 0) + high_total = total_cost - total_mid - metal_process_mid + total_high + float(metal_costing.get("process_high") or 0) + batch_qty = int(processes["batch_qty"]) + ledger = self._cost_ledger( + material_lines, + edge_costing, + finish_material, + hardware_bom, + processes, + packaging, + manufacturing, + metal_costing, + ) + if abs(float(ledger["total"]) - total_cost) > 0.01: + raise SolidWoodCostError( + f"成本台账总计校验失败:台账{float(ledger['total']):.2f},报价{total_cost:.2f}" + ) + # Raw vision-model assumptions are retained in the analysis audit only. + # They have no source/provenance flag and can vary between otherwise + # identical requests, so they must never enter a professional quote. + # Every public quote assumption below is reconstructed from user choices, + # deterministic CAD/joinery facts, and versioned costing rules. + return { + "selections": json.loads(json.dumps(selections, ensure_ascii=False)), + "price_version": self._active_price_version(), + "panel_price_version": panel_price_version, + "metal_price_version": metal_costing.get("price_version"), + "product_name": analysis["product_name"], + "overall_dimensions_mm": analysis["overall_dimensions_mm"], + "material_lines": material_lines, + "process_costing": processes, + "panel_edge_costing": edge_costing, + "hardware_bom": hardware_bom, + "production_joinery": analysis.get("production_joinery") or {}, + "packaging_bom": packaging, + "manufacturing_costing": manufacturing, + "metal_costing": metal_costing, + "cost_ledger": ledger, + "historical_bom_benchmark": history_benchmark, + "summary": { + "cost_model_version": self.COST_MODEL_VERSION, + "wood_low": wood_material_low, "wood_mid": wood_material_mid, "wood_high": wood_material_high, + "material_low": total_low, "material_mid": total_mid, "material_high": total_high, + "material_family": ( + "metal" if self._is_all_metal_selection(selections) + else "engineered_panel_metal" if panel_mode and metal_mode + else "solid_wood_metal" if metal_mode + else "engineered_panel" if panel_mode + else "solid_wood" + ), + "panel_code": panel_code if panel_mode else "", + "emission_grade": selections.get("emission_grade", "") if panel_mode else "", + "panel_purchase_state": selections.get("panel_purchase_state", "") if panel_mode else "", + "edge_scope": selections.get("edge_scope", "") if panel_mode else "", + "edge_length_m": edge_length_m, + "edge_material_cost": edge_cost_mid, + "hardware": hardware_cost, + "hardware_formula_cost": hardware_formula_cost, + "hardware_history_reference": hardware_history_reference, + "hardware_history_weight": history_weight, + "coating_area_m2": coating_area, + "finish_name": finish_name, + "finish_prefinished_panel": bool(finish.get("prefinished_panel")), + "finish_color": selections.get("finish_color", ""), + "finish_gloss": selections.get("finish_gloss", ""), + "finish_texture": selections.get("finish_texture", ""), + "finish_liters": finish_liters, + "finish_material": finish_material, + "surface_material": surface_material, + "wood_hours": processes["wood_hours"], + "finish_hours": processes["finish_hours"], + "packaging_hours": processes["packaging_hours"], + "labor_rate": 0.0, + "wood_labor": wood_labor, + "finish_labor": finish_labor, + "packaging_labor": packaging_labor, + "labor_cost": labor_cost, + "overhead_rate": 0.0, + "overhead_version": manufacturing["version"], + "overhead": overhead, + "batch_qty": batch_qty, + "shipment_mode": packaging["shipment_mode"], + "shipping_scenario": packaging["shipping_scenario"], + "package_count": packaging["package_count"], + "package_dimensions_mm": packaging["package_dimensions_mm"], + "total_packaging_volume_m3": packaging["total_packaging_volume_m3"], + "volume_weight_kg": packaging["volume_weight_kg"], + "packaging_name": packaging["shipping_scenario"], + "packaging_profile": packaging["packaging_profile"], + "packaging_box_style": packaging["packaging_box_style"], + "packaging_box_style_label": packaging["packaging_box_style_label"], + "packaging_box_style_standard": packaging["packaging_box_style_standard"], + "carton_grade": packaging["carton_grade"], + "carton_grade_label": packaging["carton_grade_label"], + "carton_cost_multiplier": packaging["carton_cost_multiplier"], + "carton_grade_specification_status": packaging["carton_grade_specification_status"], + "carton_unit_price_per_m2": packaging["carton_unit_price_per_m2"], + "carton_blank_area_m2": packaging["carton_blank_area_m2"], + "packaging_rate": 0.0, + "packaging_cost": packaging_cost, + "finish_total": finish_material + finish_labor, + "metal_material": float(metal_costing.get("material_mid") or 0), + "metal_process": metal_process_mid, + "metal_weight_kg": float(metal_costing.get("purchase_weight_kg") or 0), + "metal_coating_area_m2": float(metal_costing.get("coating_area_m2") or 0), + "metal_weld_length_m": float(metal_costing.get("weld_length_m") or 0), + "packaging_total": packaging_cost + packaging_labor, + "finish_coverage_m2_l": finish["coverage_m2_l"], + "finish_coats": finish["coats"], + "finish_loss_rate": finish["loss_rate"], + "finish_price_per_l": finish["price_per_l"], + "total_low": low_total, + "total_mid": total_cost, + "total_high": high_total, + "batch_total_mid": total_cost * batch_qty, + "history_sample_count": int(history_benchmark.get("sample_count") or 0), + "history_confidence": history_confidence, + "history_packaging_reference": self._float((history_benchmark.get("metrics") or {}).get("packaging_cost"), 0.0) or 0.0, + "history_labor_misc_reference": self._float((history_benchmark.get("metrics") or {}).get("labor_misc_cost"), 0.0) or 0.0, + "history_total_cost_reference": self._float((history_benchmark.get("metrics") or {}).get("total_cost"), 0.0) or 0.0, + }, + "assumptions": [ + f"人工按工艺路线、{batch_qty}件批量的换线摊销和有效生产小时费率计算;费率已含正常效率,不再二次除以{self.production_efficiency:.0%}", + str(processes["standard_version"]["source_note"]), + str(processes["labor_rate_version"]["source_note"]), + f"{finish_name}暂按{finish['coverage_m2_l']:.0f}㎡/L/遍、{finish['coats']}遍、{finish['loss_rate']:.0%}施工损耗", + f"包装按{packaging['shipment_mode']}、{packaging['packaging_box_style_label']}({packaging['packaging_box_style_standard']})、" + f"{packaging['carton_grade_label']}(¥{packaging['carton_unit_price_per_m2']:.2f}/㎡)、" + f"{packaging['shipping_scenario']}、{packaging['package_count']}个包裹的逐项包材BOM计算,不再按成本比例反算", + str(packaging["price_source_note"]), + hardware_bom["note"], + ] + ( + [ + f"人造板按2440×1220标准板、逐件展开面积和排版利用率折算张数;环保等级为{selections.get('emission_grade', 'E0')}", + str(next((line.get("procurement_basis") for line in material_lines if line.get("procurement_basis")), "按独立价格库核算")), + "板厚按%s匹配最接近的标准厚度,偏差已逐行输出" % ( + "BOM逐件识别" + if selections.get("panel_thickness_mode") == "bom" + else ( + "主体%gmm、背板和抽底%gmm" + % ( + float(selections.get("panel_main_thickness_mm") or 0), + float(selections.get("panel_thin_thickness_mm") or 0), + ) + if selections.get("panel_thickness_mode") == "grouped" + else "统一%gmm" % float(selections.get("panel_thickness_mm") or 0) + ) + ), + f"按{selections.get('edge_scope', '已确认范围')}封边,总长{edge_length_m:.3f}m;封边材料¥{edge_cost_mid:.2f}并另计封边工时", + f"板材采购状态:{selections.get('panel_purchase_state', '未记录')};成品饰面板不重复计贴面材料和压贴人工", + str(manufacturing["rule"]), + str((panel_price_version or {}).get("source_note") or ""), + str(packaging.get("public_design_basis") or ""), + ] + if panel_mode + else [] + ) + ( + [ + str(metal_costing.get("rule") or ""), + f"金属材料{metal_costing.get('material_name')},{metal_costing.get('profile')},壁厚{float(metal_costing.get('wall_thickness_mm') or 0):g}mm;采购重量{float(metal_costing.get('purchase_weight_kg') or 0):.3f}kg", + f"金属表面处理按外露面积{float(metal_costing.get('coating_area_m2') or 0):.3f}㎡计量;焊缝按{float(metal_costing.get('weld_length_m') or 0):.3f}m计量,零驱动不收费", + str((metal_costing.get("price_version") or {}).get("source_note") or ""), + str((metal_costing.get("process_version") or {}).get("source_note") or ""), + ] + if metal_mode + else [] + ) + ( + [ + f"公司历史BOM命中{int(history_benchmark.get('sample_count') or 0)}个同品类样本,置信度{history_confidence};只校验BOM完整性并校准五金,包材、人工和总成本仅作偏差对照", + str(history_benchmark.get("usage_rule") or ""), + ] + if history_benchmark.get("status") == "matched" + else [str(history_benchmark.get("reason") or "在线估价不使用公司同款BOM成本")] + ), + } + + @staticmethod + def _thumbnail_font(size: int, *, bold: bool = False): + candidates = [ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/STHeiti Medium.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" if bold else "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ] + for candidate in candidates: + try: + if pathlib.Path(candidate).is_file(): + return PILImageFont.truetype(candidate, size=size) + except OSError: + continue + return PILImageFont.load_default() + + @staticmethod + def _shade(hex_color: str, factor: float) -> tuple[int, int, int]: + value = str(hex_color or "#2F80ED").lstrip("#") + rgb = [int(value[index:index + 2], 16) for index in (0, 2, 4)] + return tuple(max(0, min(255, int(channel * factor))) for channel in rgb) + + @classmethod + def _render_component_thumbnail(cls, line: dict, path: pathlib.Path, color: str) -> None: + width, height = 360, 300 + image = PILImage.new("RGB", (width, height), "#F7F9FC") + draw = PILImageDraw.Draw(image) + draw.rounded_rectangle((2, 2, width - 3, height - 3), radius=18, fill="#FFFFFF", outline="#D8DEE9", width=2) + title_font = cls._thumbnail_font(22, bold=True) + name_font = cls._thumbnail_font(18, bold=True) + info_font = cls._thumbnail_font(15) + part_id = str(line.get("part_id") or "配件")[:28] + part_name = str(line.get("name") or "未命名配件")[:24] + draw.text((22, 18), part_id, fill="#172033", font=title_font) + draw.text((22, 50), part_name, fill="#4B5565", font=name_font) + + dims = line.get("dimensions_mm") or {} + x = max(1.0, float(dims.get("x") or 1)) + y = max(1.0, float(dims.get("y") or 1)) + z = max(1.0, float(dims.get("z") or 1)) + longest = max(x, y, z) + sx = max(18.0, 130.0 * x / longest) + sy = max(16.0, 86.0 * y / longest) + sz = max(12.0, 90.0 * z / longest) + total_width = sx + sy * 0.72 + origin = (width / 2 - (sx - sy * 0.72) / 2, 232.0) + vx = (sx, -sx * 0.30) + vy = (-sy * 0.72, -sy * 0.38) + vz = (0.0, -sz) + + def point(*vectors: tuple[float, float]) -> tuple[float, float]: + return ( + origin[0] + sum(vector[0] for vector in vectors), + origin[1] + sum(vector[1] for vector in vectors), + ) + + p000 = point() + p100 = point(vx) + p010 = point(vy) + p110 = point(vx, vy) + p001 = point(vz) + p101 = point(vx, vz) + p011 = point(vy, vz) + p111 = point(vx, vy, vz) + outline = "#344054" + draw.polygon((p000, p100, p101, p001), fill=cls._shade(color, 0.82), outline=outline) + draw.polygon((p000, p010, p011, p001), fill=cls._shade(color, 0.66), outline=outline) + draw.polygon((p001, p101, p111, p011), fill=cls._shade(color, 1.04), outline=outline) + draw.line((p100, p110, p111), fill=outline, width=2) + draw.line((p010, p110), fill=outline, width=2) + draw.rounded_rectangle((18, 260, width - 18, 286), radius=8, fill="#EEF2F7") + dimension_text = f"{x:g} × {y:g} × {z:g} mm · {int(line.get('quantity') or 1)}件" + draw.text((29, 265), dimension_text, fill="#334155", font=info_font) + image.save(path, format="PNG", optimize=True) + + @classmethod + def _write_component_images(cls, job_dir: pathlib.Path, project_id: str, quote: dict) -> dict: + image_dir = job_dir / "component-images" + image_dir.mkdir(parents=True, exist_ok=True) + artifacts: list[dict] = [] + for index, line in enumerate(quote.get("material_lines") or []): + color = cls.COMPONENT_COLORS[index % len(cls.COMPONENT_COLORS)] + safe_part_id = re.sub(r"[^A-Za-z0-9_-]+", "-", str(line.get("part_id") or f"PART-{index + 1:03d}")) + filename = f"{project_id}-part-{safe_part_id}.png" + path = image_dir / filename + cls._render_component_thumbnail(line, path, color) + line["component_color"] = color + line["component_image_filename"] = filename + artifacts.append( + { + "part_id": str(line.get("part_id") or ""), + "part_name": str(line.get("name") or ""), + "dimensions_mm": dict(line.get("dimensions_mm") or {}), + "color": color, + "filename": filename, + "path": str(path), + } + ) + if not artifacts: + return {"component_image_paths": [], "component_contact_sheet_path": ""} + columns = 3 + card_width, card_height = 300, 250 + rows = math.ceil(len(artifacts) / columns) + contact = PILImage.new("RGB", (columns * card_width, rows * card_height + 64), "#EEF2F7") + heading = PILImageDraw.Draw(contact) + heading.text((24, 17), "BOM配件图总览", fill="#172033", font=cls._thumbnail_font(25, bold=True)) + for index, artifact in enumerate(artifacts): + thumb = PILImage.open(artifact["path"]).convert("RGB") + thumb.thumbnail((card_width - 16, card_height - 16), PILImage.Resampling.LANCZOS) + x = (index % columns) * card_width + (card_width - thumb.width) // 2 + y = 64 + (index // columns) * card_height + (card_height - thumb.height) // 2 + contact.paste(thumb, (x, y)) + contact_path = job_dir / f"{project_id}-component-overview.png" + contact.save(contact_path, format="PNG", optimize=True) + return { + "component_image_paths": artifacts, + "component_contact_sheet_path": str(contact_path), + } + + @staticmethod + def _wrap_drawing_text(value: str, width: int = 42) -> list[str]: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return [] + return [text[index:index + width] for index in range(0, len(text), width)] + + @classmethod + def _manufacturing_drawing_manifest(cls, analysis: dict, quote: dict) -> dict: + joinery = dict(analysis.get("production_joinery") or {}) + blockers: list[dict] = [ + dict(item) for item in (joinery.get("validation_issues") or []) + if str(item.get("severity") or "") == "blocking" + ] + components_by_id = { + str(item.get("part_id") or ""): item for item in (analysis.get("components") or []) + } + parts: list[dict] = [] + for line in quote.get("material_lines") or []: + part_id = str(line.get("part_id") or "") + source = components_by_id.get(part_id) or {} + dims = {axis: float((line.get("dimensions_mm") or {}).get(axis) or 0.0) for axis in ("x", "y", "z")} + invalid_dimensions = [axis for axis, value in dims.items() if value <= 0] + if invalid_dimensions: + blockers.append( + { + "part_id": part_id, + "severity": "blocking", + "code": "invalid_part_dimensions", + "message": f"零件尺寸缺失:{','.join(invalid_dimensions)}", + } + ) + confidence = float(source.get("confidence") or 0.0) + if source and confidence < 0.70: + blockers.append( + { + "part_id": part_id, + "severity": "blocking", + "code": "low_confidence_component", + "message": f"零件识别置信度{confidence:.0%}低于生产图阈值70%", + } + ) + features = json.loads( + json.dumps(line.get("manufacturing_features") or {"holes": [], "grooves": []}, ensure_ascii=False) + ) + source_key = cls._component_key(source) + mirrored_pair = ( + int(line.get("quantity") or 1) == 2 + and any(token in source_key for token in ("侧板", "side panel", "left side", "right side")) + and "抽屉" not in source_key + ) + axis_values = sorted(((axis, value) for axis, value in dims.items()), key=lambda item: item[1]) + thickness_axis, finished_thickness = axis_values[0] + finished_planar = sorted((value for axis, value in axis_values[1:]), reverse=True) + feature_count = len(features.get("holes") or []) + len(features.get("grooves") or []) + feature_page_size = 23 + for feature in list(features.get("holes") or []) + list(features.get("grooves") or []): + if str(feature.get("validation_status") or "valid") != "valid": + blockers.append( + { + "part_id": part_id, + "feature_id": feature.get("feature_id"), + "severity": "blocking", + "code": "invalid_manufacturing_feature", + "message": "孔槽几何校验未通过", + } + ) + parts.append( + { + "part_id": part_id, + "drawing_number": "DLX-" + hashlib.sha1( + f"{quote.get('product_name') or analysis.get('product_name') or ''}:{part_id}".encode("utf-8") + ).hexdigest()[:8].upper(), + "part_name": str(line.get("name") or ""), + "quantity": int(line.get("quantity") or 1), + "material_name": str(line.get("species") or ""), + "material_family": str(line.get("material_family") or ""), + "dimensions_mm": dims, + "axis_dimensions_mm": dims, + "manufacturing_size_mm": { + "length": float(finished_planar[0]), + "width": float(finished_planar[1]), + "thickness": float(finished_thickness), + }, + "thickness_axis": thickness_axis, + "datum": (features.get("coordinate_system") or {}).get("datum") or "左-前-下角", + "coordinate_system": features.get("coordinate_system") or {}, + "holes": list(features.get("holes") or []), + "grooves": list(features.get("grooves") or []), + "feature_table_page_size": feature_page_size, + "feature_table_page_count": max(1, int(math.ceil(feature_count / feature_page_size))), + "orthographic_views": ["XY俯视图", "XZ正视图", "YZ侧视图"], + "projection_method": "第一角法", + "general_dimension_tolerance": "GB/T 1804-m(未注线性尺寸)", + "surface_and_edge_note": str(line.get("edge_length_rule") or "按BOM已确认饰面/封边范围"), + "installation_handedness": "一左一右镜像加工/装配" if mirrored_pair else "同图同向", + } + ) + hardware_names = " ".join( + str(item.get("name") or "") for item in (quote.get("hardware_bom") or {}).get("lines") or [] + ) + selections = quote.get("selections") or {} + exact_hardware = selections.get("hardware_template_skus") or {} + for keyword, code in ( + ("铰链", "hinge_template_missing"), + ("导轨", "slide_template_missing"), + ("轨道", "sliding_track_template_missing"), + ("拉手", "handle_template_missing"), + ("柜腿", "leg_connector_template_missing"), + ): + if keyword in hardware_names and not exact_hardware.get(keyword): + blockers.append( + { + "severity": "blocking", + "code": code, + "message": f"{keyword}只有通用成本规格,未绑定厂商SKU/CAD排钻模板", + } + ) + if (quote.get("metal_costing") or {}).get("material_lines"): + blockers.append( + { + "severity": "blocking", + "code": "metal_weld_detail_review_required", + "message": "金属零件需补齐焊缝接头类型、焊缝符号、焊脚尺寸与夹具基准后才能发布", + } + ) + unique_blockers: list[dict] = [] + seen: set[tuple] = set() + for item in blockers: + key = (str(item.get("part_id") or ""), str(item.get("feature_id") or ""), str(item.get("code") or item.get("message") or "")) + if key not in seen: + seen.add(key) + unique_blockers.append(item) + automated_status = "production_ready" if not unique_blockers else "drawing_draft" + return { + "schema": "dalongxia.manufacturing-drawing.v1", + "cost_model_version": cls.COST_MODEL_VERSION, + "drawing_standard": "QB/T 1338-2012", + "drawing_revision": "A", + "projection_method": "第一角法", + "drawing_scale": "自动适配(DXF模型空间1:1,单位mm)", + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "drafter": "AI自动草案", + "checker": "待结构工程师", + "approver": "待签批", + "technical_baselines": [ + {"standard": "QB/T 1338-2012", "purpose": "家具制图"}, + {"standard": "GB/T 14689-2008", "purpose": "图纸幅面和格式"}, + {"standard": "GB/T 4458.4-2003", "purpose": "尺寸注法"}, + {"standard": "GB/T 1804-2000", "purpose": "未注线性和角度尺寸一般公差"}, + {"standard": "GB/T 324-2008", "purpose": "焊缝符号(含金属产品时)"}, + {"standard": "GB 28008-2024", "purpose": "家具结构安全"}, + {"standard": "GB/T 3324-2024", "purpose": "木家具通用技术条件"}, + ], + "product_name": str(quote.get("product_name") or analysis.get("product_name") or ""), + "overall_dimensions_mm": dict(quote.get("overall_dimensions_mm") or {}), + "material_connection_rule_version": joinery.get("connection_rule_version"), + "hardware_template_version": joinery.get("hardware_template_version"), + "automated_validation_status": automated_status, + "approval_status": "awaiting_engineering_release" if automated_status == "production_ready" else "blocked", + "release_status": "drawing_draft", + "release_banner": ( + "自动校验已通过,等待结构工程师签批后发布" + if automated_status == "production_ready" + else "生产图草案—存在阻断项,未批准不得下料" + ), + "release_blockers": unique_blockers, + "rejected_incompatible_features": list(joinery.get("rejected_incompatible_features") or []), + "parts": parts, + "required_release_checks": [ + "BOM部件数量与组装CAD一致", + "材料、板厚、环保等级和饰面已确认", + "所有孔槽坐标在零件边界内且成对连接一致", + "铰链、滑轨、拉手、柜腿连接件等品牌五金已绑定厂商模板", + "金属焊缝符号、焊脚尺寸和焊缝长度已复核", + "安全结构复核与工程师签批已完成", + ], + } + + @classmethod + def _drawing_dxf_for_part(cls, part: dict) -> str: + dims = {axis: max(0.0, float((part.get("dimensions_mm") or {}).get(axis) or 0.0)) for axis in ("x", "y", "z")} + thickness_axis = min(dims, key=dims.get) + plane_axes = [axis for axis in ("x", "y", "z") if axis != thickness_axis] + u_axis, v_axis = sorted(plane_axes, key=lambda axis: dims[axis], reverse=True) + width, height = dims[u_axis], dims[v_axis] + entities: list[str] = [] + + def line(x1: float, y1: float, x2: float, y2: float, layer: str = "OUTLINE") -> None: + entities.extend(["0", "LINE", "8", layer, "10", f"{x1:.3f}", "20", f"{y1:.3f}", "30", "0", "11", f"{x2:.3f}", "21", f"{y2:.3f}", "31", "0"]) + + line(0, 0, width, 0) + line(width, 0, width, height) + line(width, height, 0, height) + line(0, height, 0, 0) + edge_note_index = 0 + for hole in part.get("holes") or []: + if str(hole.get("axis") or "") != thickness_axis: + center = hole.get("center_mm") or {} + entities.extend([ + "0", "TEXT", "8", "EDGE_DRILL_NOTE", "10", "0", + "20", f"{-max(20.0, height * 0.08) - 12.0 * edge_note_index:.3f}", + "30", "0", "40", "6", "1", + ( + f"{hole.get('feature_id') or 'EDGE'} SIDE-DRILL AXIS={hole.get('axis')} " + f"D={float(hole.get('diameter_mm') or 0):g} DEPTH={float(hole.get('depth_mm') or 0):g} " + f"XYZ=({float(center.get('x') or 0):g},{float(center.get('y') or 0):g},{float(center.get('z') or 0):g})" + ), + ]) + edge_note_index += 1 + continue + center = hole.get("center_mm") or {} + entities.extend([ + "0", "CIRCLE", "8", "HOLE", "10", f"{float(center.get(u_axis) or 0):.3f}", + "20", f"{float(center.get(v_axis) or 0):.3f}", "30", "0", "40", + f"{float(hole.get('diameter_mm') or 0) / 2:.3f}", + ]) + for groove in part.get("grooves") or []: + center = groove.get("center_mm") or {} + size = groove.get("size_mm") or {} + x1 = float(center.get(u_axis) or 0) - float(size.get(u_axis) or 0) / 2 + x2 = float(center.get(u_axis) or 0) + float(size.get(u_axis) or 0) / 2 + y1 = float(center.get(v_axis) or 0) - float(size.get(v_axis) or 0) / 2 + y2 = float(center.get(v_axis) or 0) + float(size.get(v_axis) or 0) / 2 + line(x1, y1, x2, y1, "GROOVE") + line(x2, y1, x2, y2, "GROOVE") + line(x2, y2, x1, y2, "GROOVE") + line(x1, y2, x1, y1, "GROOVE") + label = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(part.get("part_id") or "PART"))[:60] + entities.extend(["0", "TEXT", "8", "DIMENSION", "10", "0", "20", f"{-max(10.0, height * 0.08):.3f}", "30", "0", "40", "8", "1", f"{label} {width:g}x{height:g} THK={dims[thickness_axis]:g}mm"]) + return "\n".join([ + "0", "SECTION", "2", "HEADER", "9", "$ACADVER", "1", "AC1009", "9", "$INSUNITS", "70", "4", "0", "ENDSEC", + "0", "SECTION", "2", "ENTITIES", *entities, "0", "ENDSEC", "0", "EOF", "", + ]) + + @classmethod + def _render_manufacturing_drawing_page( + cls, + manifest: dict, + part: dict | None = None, + feature_page: int = 0, + ) -> PILImage.Image: + width, height = 2339, 1654 + image = PILImage.new("RGB", (width, height), "#FFFFFF") + draw = PILImageDraw.Draw(image) + title_font = cls._thumbnail_font(42, bold=True) + heading_font = cls._thumbnail_font(28, bold=True) + body_font = cls._thumbnail_font(23) + small_font = cls._thumbnail_font(19) + banner_color = "#B42318" if manifest.get("release_status") == "drawing_draft" else "#027A48" + draw.rectangle((0, 0, width, 92), fill="#17365D") + draw.text((48, 21), "产品生产工艺图", fill="#FFFFFF", font=title_font) + draw.rectangle((0, 92, width, 148), fill=banner_color) + draw.text((48, 105), str(manifest.get("release_banner") or ""), fill="#FFFFFF", font=body_font) + if part is None: + draw.text((70, 205), str(manifest.get("product_name") or "未命名产品"), fill="#172033", font=title_font) + dims = manifest.get("overall_dimensions_mm") or {} + draw.text((70, 275), f"整体尺寸:{float(dims.get('x') or 0):g} × {float(dims.get('y') or 0):g} × {float(dims.get('z') or 0):g} mm", fill="#344054", font=heading_font) + draw.text((70, 345), f"图纸标准:{manifest.get('drawing_standard')} | 成本模型:{manifest.get('cost_model_version')}", fill="#475467", font=body_font) + validation_label = "通过" if manifest.get("automated_validation_status") == "production_ready" else "存在阻断项" + release_label = "已签批可生产" if manifest.get("release_status") == "production_released" else "生产图草案" + draw.text((70, 420), f"自动校验:{validation_label} | 发布状态:{release_label}", fill=banner_color, font=heading_font) + y = 510 + draw.text((70, y), "发布阻断项", fill="#172033", font=heading_font) + y += 52 + blockers = manifest.get("release_blockers") or [] + if not blockers: + draw.text((90, y), "无自动阻断项;等待结构工程师签批。", fill="#027A48", font=body_font) + for index, blocker in enumerate(blockers[:15], 1): + for row in cls._wrap_drawing_text(f"{index}. {blocker.get('part_id') or ''} {blocker.get('message') or ''}", 70): + draw.text((90, y), row, fill="#B42318", font=small_font) + y += 35 + checks_y = min(max(y + 45, 760), 1260) + draw.text((70, checks_y), "发布前检查", fill="#172033", font=heading_font) + checks_y += 48 + for index, item in enumerate((manifest.get("required_release_checks") or [])[:6], 1): + draw.text((90, checks_y), f"□ {index}. {item}", fill="#344054", font=small_font) + checks_y += 34 + footer_top = height - 205 + draw.rectangle((55, footer_top, width - 55, height - 42), outline="#667085", width=2) + draw.text((75, footer_top + 18), f"版本:{manifest.get('drawing_revision')} | 投影:{manifest.get('projection_method')} | 比例:{manifest.get('drawing_scale')}", fill="#344054", font=small_font) + draw.text((75, footer_top + 60), f"制图:{manifest.get('drafter')} | 审核:{manifest.get('checker')} | 批准:{manifest.get('approver')}", fill="#344054", font=small_font) + draw.text((75, footer_top + 102), "本图包仅在系统标记“已签批可生产”且签批完成后方可下料。", fill="#B42318", font=small_font) + return image + + part_id = str(part.get("part_id") or "") + draw.text((65, 185), f"{part_id} {part.get('part_name') or ''}", fill="#172033", font=title_font) + dims = part.get("axis_dimensions_mm") or part.get("dimensions_mm") or {} + finished = part.get("manufacturing_size_mm") or {} + info = ( + f"数量 {int(part.get('quantity') or 1)} | 材料 {part.get('material_name') or ''} | " + f"下料尺寸 L×W×T {float(finished.get('length') or 0):g}×{float(finished.get('width') or 0):g}×{float(finished.get('thickness') or 0):g} mm | " + f"产品坐标 X×Y×Z {float(dims.get('x') or 0):g}×{float(dims.get('y') or 0):g}×{float(dims.get('z') or 0):g} | 基准 {part.get('datum') or ''}" + ) + draw.text((65, 250), info, fill="#344054", font=body_font) + features = list(part.get("holes") or []) + list(part.get("grooves") or []) + page_size = max(1, int(part.get("feature_table_page_size") or 23)) + page_count = max(1, int(math.ceil(len(features) / page_size))) + feature_page = max(0, min(int(feature_page), page_count - 1)) + if part.get("installation_handedness") == "一左一右镜像加工/装配": + draw.text((65, 295), "方向说明:数量2,一左一右镜像加工;DXF为基准侧,另一侧按X轴镜像。", fill="#B54708", font=small_font) + def draw_view(box: tuple[int, int, int, int], u_axis: str, v_axis: str, normal_axis: str, title: str) -> None: + left, top, right, bottom = box + draw.rectangle(box, outline="#98A2B3", width=2) + part_width = max(1.0, float(dims.get(u_axis) or 1)) + part_height = max(1.0, float(dims.get(v_axis) or 1)) + scale = min((right - left - 100) / part_width, (bottom - top - 100) / part_height) + ox = left + 50 + oy = bottom - 42 + rw, rh = part_width * scale, part_height * scale + visible_holes = [ + item for item in (part.get("holes") or []) + if str(item.get("axis") or "").lower() == normal_axis + ] + visible_grooves = [ + item for item in (part.get("grooves") or []) + if float((item.get("size_mm") or {}).get(u_axis) or 0) > 0 + and float((item.get("size_mm") or {}).get(v_axis) or 0) > 0 + ] + draw.text( + (left + 18, top + 12), + f"{title} {u_axis.upper()}{v_axis.upper()}(沿{normal_axis.upper()}轴) · 孔{len(visible_holes)} · 槽{len(visible_grooves)}", + fill="#344054", + font=small_font, + ) + draw.rectangle((ox, oy - rh, ox + rw, oy), outline="#175CD3", width=4) + draw.line((ox, oy + 18, ox + rw, oy + 18), fill="#175CD3", width=2) + draw.line((ox, oy + 10, ox, oy + 26), fill="#175CD3", width=2) + draw.line((ox + rw, oy + 10, ox + rw, oy + 26), fill="#175CD3", width=2) + draw.text((ox + max(0, rw / 2 - 50), oy + 22), f"{part_width:g}mm", fill="#175CD3", font=small_font) + draw.text((max(left + 4, ox - 48), oy - rh / 2), f"{part_height:g}", fill="#175CD3", font=small_font) + for hole in visible_holes: + center = hole.get("center_mm") or {} + cx = ox + float(center.get(u_axis) or 0) * scale + cy = oy - float(center.get(v_axis) or 0) * scale + radius = max(4.0, min(13.0, float(hole.get("diameter_mm") or 0) * scale / 2)) + draw.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), outline="#D92D20", width=3) + draw.line((cx - 8, cy, cx + 8, cy), fill="#D92D20", width=2) + draw.line((cx, cy - 8, cx, cy + 8), fill="#D92D20", width=2) + for groove in visible_grooves: + center = groove.get("center_mm") or {} + size = groove.get("size_mm") or {} + groove_u = float(size.get(u_axis) or 0) + groove_v = float(size.get(v_axis) or 0) + if groove_u <= 0 or groove_v <= 0: + continue + gx1 = ox + (float(center.get(u_axis) or 0) - groove_u / 2) * scale + gx2 = ox + (float(center.get(u_axis) or 0) + groove_u / 2) * scale + gy1 = oy - (float(center.get(v_axis) or 0) + groove_v / 2) * scale + gy2 = oy - (float(center.get(v_axis) or 0) - groove_v / 2) * scale + draw.rectangle((gx1, gy1, gx2, gy2), outline="#7F56D9", width=3) + + if feature_page == 0: + draw_view((75, 345, 1430, 755), "x", "y", "z", "俯视图") + draw_view((75, 800, 900, 1245), "x", "z", "y", "正视图") + draw_view((945, 800, 1430, 1245), "y", "z", "x", "侧视图") + table_x, table_y, wrap_width = 1490, 345, 48 + else: + table_x, table_y, wrap_width = 75, 345, 96 + draw.rectangle((60, 330, width - 60, 1320), outline="#98A2B3", width=2) + draw.text( + (table_x, table_y), + f"孔槽加工表 · 第{feature_page + 1}/{page_count}页", + fill="#172033", + font=heading_font, + ) + y = table_y + 57 + page_features = features[feature_page * page_size:(feature_page + 1) * page_size] + for feature in page_features: + if feature in (part.get("holes") or []): + center = feature.get("center_mm") or {} + text = f"{feature.get('feature_id')} 孔 Φ{float(feature.get('diameter_mm') or 0):g} 深{float(feature.get('depth_mm') or 0):g} ({float(center.get('x') or 0):g},{float(center.get('y') or 0):g},{float(center.get('z') or 0):g}) {feature.get('face') or ''}" + else: + size = feature.get("size_mm") or {} + center = feature.get("center_mm") or {} + text = f"{feature.get('feature_id')} 槽 {float(size.get('x') or 0):g}×{float(size.get('y') or 0):g}×{float(size.get('z') or 0):g} 中心({float(center.get('x') or 0):g},{float(center.get('y') or 0):g},{float(center.get('z') or 0):g})" + for row in cls._wrap_drawing_text(text, wrap_width): + draw.text((table_x, y), row, fill="#344054", font=small_font) + y += 31 + y += 5 + if not page_features: + draw.text((table_x, y), "本零件无孔槽加工特征。", fill="#667085", font=small_font) + footer_top = height - 205 + draw.rectangle((55, footer_top, width - 55, height - 42), outline="#667085", width=2) + draw.line((55, footer_top + 52, width - 55, footer_top + 52), fill="#98A2B3", width=1) + draw.line((55, footer_top + 103, width - 55, footer_top + 103), fill="#98A2B3", width=1) + draw.text((75, footer_top + 14), f"图号:{part.get('drawing_number')} | 版本:{manifest.get('drawing_revision')} | 比例:自动适配(DXF 1:1) | 投影:{part.get('projection_method')}", fill="#344054", font=small_font) + draw.text((75, footer_top + 65), f"未注公差:{part.get('general_dimension_tolerance')} | 原点:{part.get('datum')} | 单位:mm | 制图:{manifest.get('drafter')} | 审核:{manifest.get('checker')}", fill="#344054", font=small_font) + draw.text((75, footer_top + 116), "生产前要求:校验材料/板厚、对应面、孔深、槽深、成对接头和五金模板版本;签批后方可下料。", fill=banner_color, font=small_font) + return image + + @classmethod + def _write_manufacturing_drawings(cls, job_dir: pathlib.Path, project_id: str, analysis: dict, quote: dict) -> dict: + manifest = cls._manufacturing_drawing_manifest(analysis, quote) + manifest_filename = f"{project_id}-production-drawings.json" + pdf_filename = f"{project_id}-production-drawings.pdf" + dxf_filename = f"{project_id}-production-drawings-dxf.zip" + manifest["artifacts"] = { + "manifest": manifest_filename, + "pdf": pdf_filename, + "dxf_bundle": dxf_filename, + } + manifest_path = job_dir / manifest_filename + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + pages = [cls._render_manufacturing_drawing_page(manifest)] + for part in manifest.get("parts") or []: + page_count = max(1, int(part.get("feature_table_page_count") or 1)) + pages.extend( + cls._render_manufacturing_drawing_page(manifest, part, feature_page) + for feature_page in range(page_count) + ) + manifest["pdf_page_count"] = len(pages) + manifest["feature_table_pagination"] = "每个零件每页最多23项孔槽;超出部分自动生成连续续页" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + pdf_path = job_dir / pdf_filename + pages[0].save(pdf_path, "PDF", resolution=150.0, save_all=True, append_images=pages[1:]) + dxf_path = job_dir / dxf_filename + with zipfile.ZipFile(dxf_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for index, part in enumerate(manifest.get("parts") or [], 1): + safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(part.get("part_id") or f"PART-{index:03d}")) + archive.writestr(f"{safe_id}.dxf", cls._drawing_dxf_for_part(part).encode("utf-8")) + archive.writestr("production-drawings.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")) + return { + "production_drawing_manifest_path": str(manifest_path), + "production_drawing_pdf_path": str(pdf_path), + "production_drawing_dxf_path": str(dxf_path), + "production_drawing_manifest": manifest, + } + + @classmethod + def _write_quote_files(cls, job_dir: pathlib.Path, project_id: str, quote: dict) -> dict: + component_files = cls._write_component_images(job_dir, project_id, quote) + json_path = job_dir / f"{project_id}-cost.json" + json_path.write_text(json.dumps(quote, ensure_ascii=False, indent=2), encoding="utf-8") + workbook = Workbook() + summary = workbook.active + summary.title = "成本汇总" + summary.append([cls.DISPLAY_NAME, quote["product_name"]]) + dims = quote["overall_dimensions_mm"] + summary.append(["整体尺寸(mm)", f"{dims['x']:.0f}×{dims['y']:.0f}×{dims['z']:.0f}"]) + price_versions = [] + for version in ( + quote.get("panel_price_version"), + quote.get("metal_price_version"), + quote.get("price_version"), + ): + label = str((version or {}).get("version_label") or "").strip() + if label and label not in price_versions: + price_versions.append(label) + summary.append(["价格版本", " / ".join(price_versions)]) + s = quote["summary"] + package_dims = s.get("package_dimensions_mm") or {} + summary.append(["生产批量", f"{int(s.get('batch_qty') or 1)}件"]) + summary.append(["出货与运输", f"{s.get('shipment_mode', '')} / {s.get('shipping_scenario', '')}"]) + summary.append([ + "包装方案", + f"{s.get('packaging_box_style_label', '纸箱')}({s.get('packaging_box_style_standard', '箱型待确认')})," + f"{s.get('carton_grade_label', '纸箱等级待确认')}¥{float(s.get('carton_unit_price_per_m2') or 0):.2f}/㎡," + f"{int(s.get('package_count') or 0)}个包裹,单包约{float(package_dims.get('x') or 0):.0f}×{float(package_dims.get('y') or 0):.0f}×{float(package_dims.get('z') or 0):.0f}mm," + f"总体积{float(s.get('total_packaging_volume_m3') or 0):.3f}m³,纸板展开{float(s.get('carton_blank_area_m2') or 0):.3f}㎡", + ]) + summary.append([]) + summary.append(["成本项目", "金额(元)"]) + header_row = summary.max_row + for label, value in ( + ("材料成本-低", s["material_low"]), ("材料成本-中", s["material_mid"]), ("材料成本-高", s["material_high"]), + ("其中:封边材料", s.get("edge_material_cost", 0)), + ("其中:金属材料", s.get("metal_material", 0)), ("金属加工与表面处理", s.get("metal_process", 0)), + ("五金暂估", s["hardware"]), ("饰面/油漆材料", s["finish_material"]), + ("木工人工", s["wood_labor"]), ("油漆人工", s["finish_labor"]), ("包装人工", s["packaging_labor"]), + ("制造费用", s["overhead"]), ("包装成本", s["packaging_cost"]), ("总成本-中值", s["total_mid"]), + ): + summary.append([label, round(value, 2)]) + factory = dict(quote.get("factory_quote") or {}) + wholesale = dict(quote.get("wholesale_quote") or {}) + if factory: + summary.append([]) + summary.append(["BOM出厂报价", "纯实木" if factory.get("pure_solid_wood") else "非纯实木"]) + summary.append(["BOM制造成本", round(float(factory.get("manufacturing_cost") or 0), 2)]) + summary.append(["工厂利润率", float(factory.get("factory_profit_rate") or 0)]) + summary.append(["工厂利润额", round(float(factory.get("factory_profit_amount") or 0), 2)]) + summary.append(["生产出厂税率", float(factory.get("factory_output_tax_rate") or 0)]) + summary.append(["生产出厂税额", round(float(factory.get("factory_output_tax_amount") or 0), 2)]) + summary.append(["BOM出厂报价", round(float(factory.get("bom_factory_quote") or 0), 2)]) + if wholesale: + summary.append([]) + summary.append(["1688批发报价", "启用" if wholesale.get("enabled") else "未启用(保留BOM出厂报价)"]) + summary.append(["BOM出厂报价", round(float(wholesale.get("bom_factory_quote") or wholesale.get("base_cost") or 0), 2)]) + summary.append(["特殊操作额外费", round(float(wholesale.get("special_operation_total") or 0), 2)]) + summary.append(["1688附加税额", round(float(wholesale.get("wholesale_tax_amount") or wholesale.get("invoice_tax_amount") or 0), 2)]) + summary.append(["目标利润空间", float(wholesale.get("profit_margin") or 0)]) + summary.append(["最终1688批发价", round(float(wholesale.get("final_quote") or 0), 2)]) + summary["A1"].font = Font(bold=True, size=15, color="FFFFFF") + summary["B1"].font = Font(bold=True, size=15, color="FFFFFF") + summary["A1"].fill = summary["B1"].fill = PatternFill("solid", fgColor="1F4E78") + for cell in summary[header_row]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="5B9BD5") + summary.column_dimensions["A"].width = 24 + summary.column_dimensions["B"].width = 48 + for row in range(header_row + 1, summary.max_row + 1): + summary.cell(row, 2).number_format = '#,##0.00' + bom = workbook.create_sheet("部件BOM") + headers = ["配件图", "部件编号", "部件名称", "数量", "尺寸X(mm)", "尺寸Y(mm)", "尺寸Z(mm)", "材料", "材料类别", "标准板厚(mm)", "采购张数", "净体积(m³)", "采购体积(m³)", "金属型材", "壁厚(mm)", "金属长度(m)", "采购重量(kg)", "成本中值(元)", "饰面面积(㎡)", "封边长度(m)", "单件孔数", "单件槽数", "单件槽长(m)", "损耗口径"] + bom.append(headers) + image_by_filename = { + item["filename"]: item for item in component_files.get("component_image_paths") or [] + } + for row_index, line in enumerate(quote["material_lines"], 2): + d = line["dimensions_mm"] + bom.append([ + "", line["part_id"], line["name"], line["quantity"], d["x"], d["y"], d["z"], line["species"], + line.get("material_family", "solid_wood"), line.get("standard_thickness_mm"), line.get("purchase_sheet_count"), + line["net_volume_m3"], line["purchase_volume_m3"], line.get("metal_profile"), line.get("wall_thickness_mm"), + line.get("metal_length_m"), line.get("purchase_weight_kg"), line["cost_mid"], line["coating_area_m2"], + line.get("edge_length_m", 0), + int((line.get("machining") or {}).get("holes") or 0), + int((line.get("machining") or {}).get("slots") or 0), + float((line.get("machining") or {}).get("slot_length_m") or 0), + line["loss_rule"], + ]) + artifact = image_by_filename.get(str(line.get("component_image_filename") or "")) + if artifact and pathlib.Path(artifact["path"]).is_file(): + embedded = XLImage(artifact["path"]) + embedded.width = 112 + embedded.height = 93 + bom.add_image(embedded, f"A{row_index}") + bom.row_dimensions[row_index].height = 74 + for cell in bom[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + cell.alignment = Alignment(horizontal="center") + widths = [18, 16, 24, 8, 12, 12, 12, 20, 16, 16, 14, 14, 14, 20, 12, 14, 15, 14, 14, 14, 12, 12, 14, 42] + for idx, width in enumerate(widths, 1): + bom.column_dimensions[chr(64 + idx)].width = width + bom.freeze_panes = "A2" + joinery_sheet = workbook.create_sheet("连接孔槽明细") + joinery_sheet.append([ + "部件编号", "部件名称", "部件数量", "特征编号", "特征类型", "工艺名称", "功能分组", + "基准", "加工面", "加工轴", "X(mm)", "Y(mm)", "Z(mm)", "直径(mm)", "槽宽(mm)", "槽深(mm)", + "单件长度(mm)", "孔类型", "连接件规格", "排钻模板", "尺寸公差(mm)", "位置公差(mm)", "校验", "计价依据", + ]) + for line in quote.get("material_lines") or []: + features = line.get("manufacturing_features") or {} + for feature in features.get("holes") or []: + center = feature.get("center_mm") or {} + joinery_sheet.append([ + line.get("part_id"), line.get("name"), line.get("quantity"), feature.get("feature_id"), + "孔", feature.get("name"), feature.get("group"), feature.get("datum"), feature.get("face"), feature.get("axis"), + center.get("x"), center.get("y"), center.get("z"), feature.get("diameter_mm"), "", feature.get("depth_mm"), "", + feature.get("hole_type"), feature.get("hardware_spec"), feature.get("hardware_template_id"), + feature.get("dimension_tolerance_mm"), feature.get("position_tolerance_mm"), feature.get("validation_status"), feature.get("source"), + ]) + for feature in features.get("grooves") or []: + size = feature.get("size_mm") or {} + cross_sections = sorted(float(value or 0) for value in size.values())[:2] + center = feature.get("center_mm") or {} + joinery_sheet.append([ + line.get("part_id"), line.get("name"), line.get("quantity"), feature.get("feature_id"), + "槽", feature.get("name"), feature.get("group"), feature.get("datum"), feature.get("face"), "—", + center.get("x"), center.get("y"), center.get("z"), "", + cross_sections[1] if len(cross_sections) > 1 else "", + cross_sections[0] if cross_sections else "", feature.get("length_mm"), + "", f"嵌板{float(feature.get('insert_thickness_mm') or 0):g}mm", "", + feature.get("dimension_tolerance_mm"), "", feature.get("validation_status"), feature.get("source"), + ]) + for cell in joinery_sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for idx, width_value in enumerate([16, 26, 12, 16, 12, 22, 22, 16, 12, 10, 12, 12, 12, 12, 12, 12, 16, 12, 22, 28, 14, 14, 12, 36], 1): + joinery_sheet.column_dimensions[chr(64 + idx)].width = width_value + joinery_sheet.freeze_panes = "A2" + release_sheet = workbook.create_sheet("生产图发布检查") + drawing_manifest = quote.get("production_drawing") or {} + release_sheet.append(["项目", "结果"]) + release_sheet.append(["自动校验状态", drawing_manifest.get("automated_validation_status")]) + release_sheet.append(["工程师审批状态", drawing_manifest.get("approval_status")]) + release_sheet.append(["正式发布状态", drawing_manifest.get("release_status")]) + release_sheet.append(["提示", drawing_manifest.get("release_banner")]) + release_sheet.append([]) + release_sheet.append(["阻断代码", "部件", "阻断原因"]) + for blocker in drawing_manifest.get("release_blockers") or []: + release_sheet.append([blocker.get("code"), blocker.get("part_id"), blocker.get("message")]) + for cell in release_sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for cell in release_sheet[7]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="5B9BD5") + release_sheet.column_dimensions["A"].width = 34 + release_sheet.column_dimensions["B"].width = 30 + release_sheet.column_dimensions["C"].width = 90 + if wholesale: + wholesale_sheet = workbook.create_sheet("1688批发报价") + wholesale_sheet.append(["计算项", "值"]) + wholesale_sheet.append(["BOM制造成本", factory.get("manufacturing_cost", wholesale.get("base_cost"))]) + wholesale_sheet.append(["工厂利润率", factory.get("factory_profit_rate")]) + wholesale_sheet.append(["工厂利润额", factory.get("factory_profit_amount")]) + wholesale_sheet.append(["工厂利润后小计", factory.get("before_output_tax")]) + wholesale_sheet.append(["生产出厂税率", factory.get("factory_output_tax_rate")]) + wholesale_sheet.append(["生产出厂税额", factory.get("factory_output_tax_amount")]) + wholesale_sheet.append(["BOM出厂报价", wholesale.get("bom_factory_quote")]) + wholesale_sheet.append(["特殊操作额外费", wholesale.get("special_operation_total")]) + wholesale_sheet.append(["1688附加税率", wholesale.get("wholesale_tax_rate", wholesale.get("invoice_tax_rate"))]) + wholesale_sheet.append(["1688附加税额", wholesale.get("wholesale_tax_amount", wholesale.get("invoice_tax_amount"))]) + wholesale_sheet.append(["目标利润空间", wholesale.get("profit_margin")]) + wholesale_sheet.append(["最终1688批发价", wholesale.get("final_quote")]) + wholesale_sheet.append(["计算公式", wholesale.get("formula")]) + wholesale_sheet.append([]) + wholesale_sheet.append(["特殊操作", "耗材口径", "计价数量", "单位", "单价", "金额"]) + for line in wholesale.get("special_operations") or []: + wholesale_sheet.append([ + line.get("operation_name"), line.get("material_basis_label"), + line.get("driver_quantity"), line.get("driver_unit"), line.get("unit_price"), line.get("amount"), + ]) + for cell in wholesale_sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for cell in wholesale_sheet[16]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="5B9BD5") + for idx, width in enumerate([30, 24, 16, 12, 14, 14], 1): + wholesale_sheet.column_dimensions[chr(64 + idx)].width = width + processes = workbook.create_sheet("工艺工时") + process_headers = ["工序", "工种", "计价驱动", "换线分钟(整批)", "换线分钟(单件)", "加工分钟(单件)", "生产效率", "计费工时(单件)", "小时费率", "人工费(单件)"] + processes.append(process_headers) + for line in (quote.get("process_costing") or {}).get("lines") or []: + processes.append([ + line["process_name"], line["worker_name"], line["driver"], line["setup_minutes_total"], + line["setup_minutes_per_product"], line["run_minutes_per_product"], line["efficiency"], + line["paid_hours_per_product"], line["hourly_rate"], line["cost_per_product"], + ]) + for cell in processes[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for idx, width in enumerate([18, 14, 58, 18, 18, 18, 12, 18, 14, 18], 1): + processes.column_dimensions[chr(64 + idx)].width = width + processes.freeze_panes = "A2" + metal_processes = workbook.create_sheet("金属工艺") + metal_processes.append(["工序", "计价驱动", "数量", "单位", "整批换线费", "单件换线费", "成本低", "成本中", "成本高", "价格依据"]) + for line in (quote.get("metal_costing") or {}).get("process_lines") or []: + metal_processes.append([ + line.get("process_name"), line.get("driver"), line.get("driver_quantity"), line.get("driver_unit"), + line.get("setup_cost_batch"), line.get("setup_cost_per_product"), line.get("cost_low"), + line.get("cost_mid"), line.get("cost_high"), line.get("source_note"), + ]) + for cell in metal_processes[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for idx, width in enumerate([20, 62, 12, 10, 15, 15, 14, 14, 14, 52], 1): + metal_processes.column_dimensions[chr(64 + idx)].width = width + metal_processes.freeze_panes = "A2" + metal_benchmark = (quote.get("metal_costing") or {}).get("company_benchmark") or {} + if metal_benchmark.get("matched"): + benchmark_sheet = workbook.create_sheet("公司金属价校准") + benchmark_sheet.append(["项目", "结果"]) + benchmark_sheet.append(["公司价格版本", metal_benchmark.get("version")]) + benchmark_sheet.append(["是否用于本次计价", "是" if metal_benchmark.get("applied") else "否,仅偏差提示"]) + benchmark_sheet.append(["同规格样本行", metal_benchmark.get("sample_count")]) + benchmark_sheet.append(["覆盖SKU", metal_benchmark.get("sku_count")]) + benchmark_sheet.append(["数据最新日期", metal_benchmark.get("latest_at")]) + benchmark_sheet.append(["公司每米中位价", metal_benchmark.get("company_price_mid_m")]) + benchmark_sheet.append(["公司口径本次材料成本", metal_benchmark.get("company_cost_mid")]) + benchmark_sheet.append(["公开公斤价折算参考", metal_benchmark.get("public_reference_cost_mid")]) + benchmark_sheet.append(["使用规则", metal_benchmark.get("rule")]) + for cell in benchmark_sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + benchmark_sheet.column_dimensions["A"].width = 28 + benchmark_sheet.column_dimensions["B"].width = 100 + hardware_sheet = workbook.create_sheet("五金BOM") + hardware_sheet.append(["五金/配件", "单位", "中位数量", "本次校准单价", "本次金额", "历史中位单价", "样本覆盖", "来源"]) + for line in (quote.get("hardware_bom") or {}).get("lines") or []: + hardware_sheet.append([ + line.get("name"), line.get("unit"), line.get("quantity"), line.get("unit_price"), line.get("cost"), + line.get("historical_median_unit_price"), + f"{int(line.get('sample_presence') or 0)}/{int(line.get('sample_count') or 0)}" if line.get("sample_count") else "-", + line.get("source"), + ]) + for cell in hardware_sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for idx, width in enumerate([34, 10, 14, 18, 16, 18, 14, 38], 1): + hardware_sheet.column_dimensions[chr(64 + idx)].width = width + hardware_sheet.freeze_panes = "A2" + package_sheet = workbook.create_sheet("包装BOM") + package_sheet.append(["包材编码", "包材", "单位", "需求量", "含税单价", "损耗率", "成本", "计算依据"]) + for line in (quote.get("packaging_bom") or {}).get("lines") or []: + package_sheet.append([ + line["material_code"], line["material_name"], line["unit"], line["quantity"], line["unit_price"], + line["waste_rate"], line["cost"], line["reason"], + ]) + for cell in package_sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for idx, width in enumerate([18, 20, 10, 14, 14, 12, 14, 42], 1): + package_sheet.column_dimensions[chr(64 + idx)].width = width + package_sheet.freeze_panes = "A2" + packing_sheet = workbook.create_sheet("包装叠放模拟") + packing_sheet.append(["包装序号", "层级角色", "节点/部件", "装箱位置X(mm)", "装箱位置Y(mm)", "装箱位置Z(mm)", "尺寸X(mm)", "尺寸Y(mm)", "尺寸Z(mm)"]) + packaging_nodes = (quote.get("packaging_bom") or {}).get("packaging_layers") or [] + for item in packaging_nodes: + position = item.get("packed_position_mm") or {} + item_dims = item.get("dimensions_mm") or {} + packing_sheet.append([ + item.get("package_index"), item.get("name") or item.get("node_role"), item.get("node_name"), + position.get("x"), position.get("y"), position.get("z"), + item_dims.get("x"), item_dims.get("y"), item_dims.get("z"), + ]) + for cell in packing_sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for idx, width in enumerate([12, 24, 30, 14, 14, 14, 14, 14, 14], 1): + packing_sheet.column_dimensions[chr(64 + idx)].width = width + packing_sheet.freeze_panes = "A2" + ledger_sheet = workbook.create_sheet("唯一成本台账") + ledger_sheet.append(["cost_line_id", "成本类别", "金额", "参与总计"]) + for line in (quote.get("cost_ledger") or {}).get("lines") or []: + ledger_sheet.append([line.get("cost_line_id"), line.get("cost_category"), line.get("amount"), "是" if line.get("summable") else "否"]) + for line in (quote.get("cost_ledger") or {}).get("subtotals") or []: + ledger_sheet.append([line.get("cost_line_id"), line.get("cost_category"), line.get("amount"), "否(仅展示)"]) + for cell in ledger_sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for idx, width in enumerate([34, 24, 16, 18], 1): + ledger_sheet.column_dimensions[chr(64 + idx)].width = width + ledger_sheet.freeze_panes = "A2" + assumptions = workbook.create_sheet("计算假设") + assumptions.append(["序号", "假设/待确认项"]) + for idx, item in enumerate(quote.get("assumptions") or [], 1): + assumptions.append([idx, item]) + assumptions.column_dimensions["A"].width = 10 + assumptions.column_dimensions["B"].width = 90 + history = quote.get("historical_bom_benchmark") or {} + if history.get("status") == "matched": + history_sheet = workbook.create_sheet("历史BOM校准") + history_sheet.append(["数据来源", history.get("source")]) + history_sheet.append(["命中样本", history.get("sample_count")]) + history_sheet.append(["校准置信度", history.get("confidence")]) + history_sheet.append(["使用边界", history.get("usage_rule")]) + history_sheet.append([]) + history_sheet.append([ + "历史SKU", "商品名称", "规格", "相似度", "材质匹配", "板件数", "板材面积(㎡)", + "封边长度(m)", "五金(元)", "包材(元)", "人工杂费(元)", "历史总成本(元)", "BOM日期", + ]) + header_row = history_sheet.max_row + for sample in history.get("samples") or []: + history_sheet.append([ + sample.get("sku_code"), sample.get("sku_name"), sample.get("sku_spec"), sample.get("score"), + "是" if sample.get("material_match") else "否", sample.get("board_part_count"), sample.get("board_area_m2"), + sample.get("edge_length_m"), sample.get("hardware_cost"), sample.get("packaging_cost"), + sample.get("labor_misc_cost"), sample.get("total_cost"), sample.get("latest_bom_at"), + ]) + for cell in history_sheet[header_row]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + for idx, width in enumerate([20, 34, 26, 12, 12, 12, 16, 16, 14, 14, 18, 18, 22], 1): + history_sheet.column_dimensions[chr(64 + idx)].width = width + history_sheet.freeze_panes = f"A{header_row + 1}" + xlsx_path = job_dir / f"{project_id}-BOM与成本报价.xlsx" + workbook.save(xlsx_path) + return { + "quote_json_path": str(json_path), + "bom_xlsx_path": str(xlsx_path), + **component_files, + } + + @staticmethod + def _completed_answer(project: dict) -> str: + result = project.get("result") or {} + quote = result.get("quote") or {} + s = quote.get("summary") or {} + files = [item for item in (result.get("files") or []) if isinstance(item, dict)] + component_urls = { + str(item.get("part_id") or ""): str(item.get("url") or item.get("download_url") or "") + for item in files + if item.get("artifact_role") == "bom_component_thumbnail" and item.get("part_id") + } + overview = next( + (item for item in files if item.get("artifact_role") == "bom_component_overview"), + {}, + ) + assembled_model = next( + (item for item in files if item.get("artifact_role") == "assembled_3d"), + {}, + ) + exploded_model = next( + ( + item for item in files + if item.get("artifact_role") == "interactive_3d" + ), + {}, + ) + packaging_model = next( + (item for item in files if item.get("artifact_role") == "packaging_3d"), + {}, + ) + production_pdf = next( + (item for item in files if item.get("artifact_role") == "production_drawing_pdf"), + {}, + ) + production_dxf = next( + (item for item in files if item.get("artifact_role") == "production_drawing_dxf_bundle"), + {}, + ) + if not assembled_model and not exploded_model: + exploded_model = next( + (item for item in files if str(item.get("mime_type") or "").lower() == "model/gltf-binary"), + {}, + ) + overview_url = str(overview.get("url") or overview.get("download_url") or "") + assembled_url = str(assembled_model.get("url") or assembled_model.get("download_url") or "") + exploded_url = str(exploded_model.get("url") or exploded_model.get("download_url") or "") + packaging_url = str(packaging_model.get("url") or packaging_model.get("download_url") or "") + production_pdf_url = str(production_pdf.get("url") or production_pdf.get("download_url") or "") + production_dxf_url = str(production_dxf.get("url") or production_dxf.get("download_url") or "") + labor_rate = float(s.get("labor_rate") or 0) + wood_labor = float(s.get("wood_labor") or (float(s.get("wood_hours") or 0) * labor_rate)) + finish_labor = float(s.get("finish_labor") or (float(s.get("finish_hours") or 0) * labor_rate)) + packaging_labor = float(s.get("packaging_labor") or (float(s.get("packaging_hours") or 0) * labor_rate)) + finish_total = float(s.get("finish_total") or (float(s.get("finish_material") or 0) + finish_labor)) + packaging_total = float(s.get("packaging_total") or (float(s.get("packaging_cost") or 0) + packaging_labor)) + finish_template = SolidWoodCostEngine.FINISH_TEMPLATES.get(str(s.get("finish_name") or ""), {}) + finish_coats = int(s.get("finish_coats") or finish_template.get("coats") or 0) + finish_coverage = float(s.get("finish_coverage_m2_l") or finish_template.get("coverage_m2_l") or 0) + finish_loss = float(s.get("finish_loss_rate") or finish_template.get("loss_rate") or 0) + finish_price = float(s.get("finish_price_per_l") or finish_template.get("price_per_l") or 0) + bom_rows = [] + for line in (quote.get("material_lines") or [])[:40]: + dims = line.get("dimensions_mm") or {} + part_id = str(line.get("part_id") or "") + image_url = component_urls.get(part_id, "") + image_cell = f"![{part_id} {line.get('name', '')}]({image_url})" if image_url else "—" + bom_rows.append( + f"| {image_cell} | {part_id} | {line.get('name', '')} | {int(line.get('quantity') or 0)} | " + f"{float(dims.get('x') or 0):g}×{float(dims.get('y') or 0):g}×{float(dims.get('z') or 0):g} | " + f"{line.get('species', '')} | ¥{float(line.get('cost_mid') or 0):,.2f} |" + ) + process_rows = [] + for line in ((quote.get("process_costing") or {}).get("lines") or [])[:30]: + process_rows.append( + f"| {line.get('process_name', '')} | {line.get('worker_name', '')} | {float(line.get('paid_hours_per_product') or 0):.2f} | " + f"¥{float(line.get('hourly_rate') or 0):,.2f}/h | ¥{float(line.get('cost_per_product') or 0):,.2f} |" + ) + metal_process_rows = [] + metal_costing = quote.get("metal_costing") or {} + for line in (metal_costing.get("process_lines") or [])[:30]: + metal_process_rows.append( + f"| {line.get('process_name', '')} | {line.get('driver', '')} | " + f"{float(line.get('driver_quantity') or 0):.3f}{line.get('driver_unit', '')} | " + f"¥{float(line.get('cost_mid') or 0):,.2f} |" + ) + package_rows = [] + packaging_bom = quote.get("packaging_bom") or {} + for line in (packaging_bom.get("lines") or [])[:30]: + package_rows.append( + f"| {line.get('material_name', '')} | {float(line.get('quantity') or 0):g}{line.get('unit', '')} | " + f"¥{float(line.get('unit_price') or 0):,.2f} | {float(line.get('waste_rate') or 0):.0%} | ¥{float(line.get('cost') or 0):,.2f} |" + ) + hardware_rows = [] + hardware_bom = quote.get("hardware_bom") or {} + for line in (hardware_bom.get("lines") or [])[:20]: + coverage = ( + f"{int(line.get('sample_presence') or 0)}/{int(line.get('sample_count') or 0)}" + if line.get("sample_count") + else "当前结构" + ) + hardware_rows.append( + f"| {line.get('name', '')} | {float(line.get('quantity') or 0):g}{line.get('unit', '')} | " + f"¥{float(line.get('unit_price') or 0):,.3f} | ¥{float(line.get('cost') or 0):,.2f} | {coverage} |" + ) + joinery = quote.get("production_joinery") or {} + joinery_section = "" + if joinery.get("applicable"): + totals = joinery.get("totals") or {} + back = joinery.get("back_panel_groove") or {} + drawer = joinery.get("drawer_bottom_groove") or {} + joinery_section = ( + "#### CAD生产连接与孔槽\n\n" + "| 项目 | 生产设计 |\n| --- | --- |\n" + f"| 柜体连接 | {joinery.get('connection_system', '')} |\n" + f"| CAD孔位 | {int(totals.get('holes') or 0)}个 |\n" + f"| CAD开槽 | {int(totals.get('grooves') or 0)}道,总长{float(totals.get('groove_length_m') or 0):.3f}m |\n" + + ( + f"| 背板槽 | 宽{float(back.get('width_mm') or 0):g}×深{float(back.get('depth_mm') or 0):g}mm,{int(back.get('count') or 0)}道 |\n" + if back.get("enabled") else "" + ) + + ( + f"| 抽屉底板槽 | 宽{float(drawer.get('width_mm') or 0):g}×深{float(drawer.get('depth_mm') or 0):g}mm,{int(drawer.get('count') or 0)}道 |\n" + if drawer.get("enabled") else "" + ) + + f"\n> {joinery.get('manufacturing_release_note', '')}。孔槽人工只按上述CAD特征计费,螺钉、木榫材料只在五金BOM计费一次。\n\n" + ) + history = quote.get("historical_bom_benchmark") or {} + manufacturing = quote.get("manufacturing_costing") or {} + manufacturing_rows = [ + f"| {line.get('resource_pool', '')} | {line.get('driver', '')} | ¥{float(line.get('cost') or 0):,.2f} |" + for line in (manufacturing.get("lines") or []) + ] + confidence_label = {"high": "高", "medium": "中", "low": "低"}.get(str(history.get("confidence") or ""), "未校准") + history_section = "" + if history.get("status") == "matched": + metrics = history.get("metrics") or {} + history_section = ( + "#### 公司历史 BOM 复核\n\n" + f"> **命中:** {int(history.get('sample_count') or 0)}个同品类样本|**置信度:** {confidence_label}|" + f"**平均相似度:** {float(history.get('average_score') or 0):.0%}\n\n" + "| 历史中位参考 | 金额 | 本次用法 |\n| --- | ---: | --- |\n" + f"| 五金配件 | ¥{float(metrics.get('hardware_cost') or 0):,.2f} | 与当前门/抽屉结构公式加权校准 |\n" + f"| 包材 | ¥{float(metrics.get('packaging_cost') or 0):,.2f} | 仅作偏差对照,不覆盖当前包装BOM |\n" + f"| 人工+杂费 | ¥{float(metrics.get('labor_misc_cost') or 0):,.2f} | 仅作偏差对照,不覆盖当前工艺工时 |\n" + f"| 总成本 | ¥{float(metrics.get('total_cost') or 0):,.2f} | 仅作异常检查,不替代当前材质与价格版本 |\n\n" + "> 历史样本只是复核层:当前图片拆件、用户确认的板厚/材质/油漆和当前价格库仍是本次报价主体。\n\n" + ) + package_dims = s.get("package_dimensions_mm") or {} + production_note = ( + f"> **核算条件:** 生产{int(s.get('batch_qty') or 1)}件,{s.get('shipment_mode', '')}," + f"{s.get('shipping_scenario', '')},{int(s.get('package_count') or 0)}个包裹," + f"{s.get('packaging_box_style_label', '纸箱')}({s.get('packaging_box_style_standard', '箱型待确认')})," + f"{s.get('carton_grade_label', '纸箱等级待确认')}(¥{float(s.get('carton_unit_price_per_m2') or 0):.2f}/㎡)," + f"{s.get('packaging_profile', '常规防护包装')}," + f"单包约{float(package_dims.get('x') or 0):.0f}×{float(package_dims.get('y') or 0):.0f}×{float(package_dims.get('z') or 0):.0f}mm," + f"总包装体积{float(s.get('total_packaging_volume_m3') or 0):.3f}m³。\n\n" + if packaging_bom + else "" + ) + packaging_explanation = ( + "包装材料按实际包装BOM和版本单价逐项计算,不按产品成本比例反算;" + + str(packaging_bom.get("public_design_basis") or "") + if packaging_bom + else "这是旧版历史报价,包装未保存逐项BOM;修改生产包装条件后可按新算法重算" + ) + display_name = SolidWoodCostEngine._display_product_name(project.get("analysis") or {}) + material_family = str(s.get("material_family") or "") + material_label = ( + "木/板材与金属材料" if "metal" in material_family and material_family != "metal" + else "金属材料" if material_family == "metal" + else "人造板及封边材料" if material_family == "engineered_panel" + else "木材" + ) + if s.get("finish_prefinished_panel"): + finish_detail = "采购成品双饰面板,成品板价差已计入板材;不再增加贴面材料、压贴人工或油漆人工" + elif finish_coats > 0: + finish_detail = ( + f"涂装面积{s.get('coating_area_m2', 0):.3f}㎡,{finish_coats}遍,覆盖率{finish_coverage:g}㎡/L/遍," + f"损耗{finish_loss:.0%},用量{s.get('finish_liters', 0):.3f}L,材料单价¥{finish_price:,.2f}/L" + ) + else: + finish_detail = f"饰面展开面积{s.get('coating_area_m2', 0):.3f}㎡,按面积及{finish_loss:.0%}损耗计价,不虚构油漆遍数和升数" + panel_note = ( + f"- **板式工艺:** {s.get('emission_grade', '')}级,按{s.get('edge_scope', '已确认范围')}封边总长{s.get('edge_length_m', 0):.3f}m," + f"封边材料¥{s.get('edge_material_cost', 0):,.2f};开料排版、封边、钻孔/开槽人工已列入工艺路线。\n" + if material_family in {"engineered_panel", "engineered_panel_metal"} + else "" + ) + metal_note = ( + f"- **金属:** {metal_costing.get('material_name', '')},{metal_costing.get('profile', '')},壁厚" + f"{float(metal_costing.get('wall_thickness_mm') or 0):g}mm,采购重量" + f"{float(metal_costing.get('purchase_weight_kg') or 0):.3f}kg,外露处理面积" + f"{float(metal_costing.get('coating_area_m2') or 0):.3f}㎡,焊缝" + f"{float(metal_costing.get('weld_length_m') or 0):.3f}m;零驱动工艺不收费。\n" + if metal_costing.get("material_lines") + else "" + ) + company_metal = metal_costing.get("company_benchmark") or {} + if company_metal.get("applied"): + deviation = company_metal.get("deviation_rate") + deviation_text = f",较公开公斤价折算{float(deviation):+.1%}" if deviation is not None else "" + metal_note += ( + f"- **公司金属价校准:** 命中同材质、同截面、同壁厚的" + f"{int(company_metal.get('sample_count') or 0):,}条BOM(覆盖" + f"{int(company_metal.get('sku_count') or 0):,}个SKU),采用¥" + f"{float(company_metal.get('company_price_mid_m') or 0):,.2f}/米" + f"{deviation_text};公司每米价与公开公斤价二选一,没有叠加。\n" + ) + elif metal_costing.get("material_lines"): + metal_note += "- **公司金属价校准:** 公司库暂无同材质、同截面、同壁厚的高置信度样本,本次使用公开公斤价折算,未把近似规格强行套用。\n" + preview_section = "" + if overview_url or assembled_url or exploded_url or packaging_url: + preview_section = "#### 3D与配件预览\n\n" + if assembled_url: + preview_section += f"[打开可旋转3D整装成品模型]({assembled_url})\n\n" + if exploded_url: + preview_section += f"[打开可交互3D爆炸拆件模型]({exploded_url})\n\n" + if packaging_url: + preview_section += f"[打开可旋转3D包装叠放模型]({packaging_url})\n\n" + if overview_url: + preview_section += f"![{display_name}配件图总览]({overview_url})\n\n" + drawing_manifest = quote.get("production_drawing") or {} + drawing_blockers = drawing_manifest.get("release_blockers") or [] + production_drawing_section = "#### 生产工艺图包\n\n" + production_drawing_section += ( + f"> **发布状态:** `{drawing_manifest.get('release_status') or '未生成'}`|" + f"**自动校验:** `{drawing_manifest.get('automated_validation_status') or '未校验'}`。" + f"{drawing_manifest.get('release_banner') or ''}\n\n" + ) + if production_pdf_url: + production_drawing_section += f"[下载部件生产工艺图PDF]({production_pdf_url})\n\n" + if production_dxf_url: + production_drawing_section += f"[下载生产工艺图DXF压缩包]({production_dxf_url})\n\n" + if drawing_blockers: + production_drawing_section += "**未批准不得下料,当前阻断项:**\n\n" + production_drawing_section += "\n".join( + f"- {item.get('part_id') or '整机'}:{item.get('message') or '需复核'}" + for item in drawing_blockers[:12] + ) + "\n\n" + factory = dict(quote.get("factory_quote") or {}) + wholesale = dict(quote.get("wholesale_quote") or {}) + archive = dict(result.get("archive") or {}) + archive_note = ( + f"> **报价档案号:** `{archive.get('archive_id')}`,已保存用户图片、会话用户/操作人、BOM、计价条件和最终报价。\n\n" + if archive.get("archive_id") else "" + ) + wholesale_section = "" + if wholesale.get("enabled"): + special_rows = "\n".join( + f"| {line.get('operation_name', '')} | {line.get('material_basis_label', '')} | " + f"{float(line.get('driver_quantity') or 0):g}{'方' if line.get('driver_unit') == 'm3' else '件'} | " + f"¥{float(line.get('amount') or 0):,.2f} |" + for line in wholesale.get("special_operations") or [] + ) + wholesale_section = ( + "#### 1688批发报价\n\n" + "| 计算项 | 金额 |\n| --- | ---: |\n" + f"| BOM出厂报价 | ¥{float(wholesale.get('bom_factory_quote') or wholesale.get('base_cost') or 0):,.2f} |\n" + f"| 特殊操作额外费 | ¥{float(wholesale.get('special_operation_total') or 0):,.2f} |\n" + f"| 1688附加税额({float(wholesale.get('wholesale_tax_rate') or wholesale.get('invoice_tax_rate') or 0):.0%}) | ¥{float(wholesale.get('wholesale_tax_amount') or wholesale.get('invoice_tax_amount') or 0):,.2f} |\n" + f"| 目标利润空间 | {float(wholesale.get('profit_margin') or 0):.1%} |\n" + f"| **最终1688批发价** | **¥{float(wholesale.get('final_quote') or 0):,.2f}** |\n\n" + + ( + "| 特殊操作 | 耗材口径 | 计价数量 | 金额 |\n| --- | --- | ---: | ---: |\n" + + special_rows + "\n\n" + if special_rows else "" + ) + + f"> 公式:{wholesale.get('formula', '')}。5%只在1688报价层计算一次,不回写BOM成本。\n\n" + ) + elif wholesale: + wholesale_section = ( + "#### 报价口径\n\n" + f"> 本次选择只保留BOM出厂报价,已含工厂利润和7%生产出厂税;未增加1688特殊操作、5%附加税或利润倒推。归档金额为 **¥{float(wholesale.get('final_quote') or 0):,.2f}**。\n\n" + ) + factory_section = "" + if factory: + factory_section = ( + "#### BOM出厂报价\n\n" + "| 报价层 | 金额 |\n| --- | ---: |\n" + f"| BOM制造成本 | ¥{float(factory.get('manufacturing_cost') or 0):,.2f} |\n" + f"| 工厂利润({'纯实木' if factory.get('pure_solid_wood') else '非纯实木'} {float(factory.get('factory_profit_rate') or 0):.0%}) | ¥{float(factory.get('factory_profit_amount') or 0):,.2f} |\n" + f"| 生产出厂税({float(factory.get('factory_output_tax_rate') or 0):.0%}) | ¥{float(factory.get('factory_output_tax_amount') or 0):,.2f} |\n" + f"| **BOM出厂报价** | **¥{float(factory.get('bom_factory_quote') or 0):,.2f}** |\n\n" + "> 工厂利润和生产出厂税属于报价层,不进入BOM成本台账,也不会被再次汇总。\n\n" + ) + return ( + f"### {SolidWoodCostEngine.DISPLAY_NAME}完成:{display_name}\n\n" + "> **结果:** 逐配件CAD、带图BOM、工艺人工、包装BOM、成本报价和生产工艺图草案已生成。生产图是否可下料以其独立发布状态为准。\n\n" + + archive_note + + production_note + + "#### 成本摘要\n\n" + "| 成本项目 | 金额 |\n| --- | ---: |\n" + f"| {material_label}成本(中值) | ¥{s.get('material_mid', s.get('wood_mid', 0)):,.2f} |\n" + + (f"| 其中:封边材料 | ¥{s.get('edge_material_cost', 0):,.2f} |\n" if s.get("edge_material_cost") else "") + + (f"| 其中:金属材料 | ¥{s.get('metal_material', 0):,.2f} |\n" if s.get("metal_material") else "") + + (f"| 金属加工与表面处理 | ¥{s.get('metal_process', 0):,.2f} |\n" if s.get("metal_process") else "") + + f"| 五金与配件 | ¥{s.get('hardware', 0):,.2f} |\n" + f"| 饰面/油漆材料({s.get('finish_name', '')}) | ¥{s.get('finish_material', 0):,.2f} |\n" + f"| 木工人工/板式加工({s.get('wood_hours', 0):.2f}小时) | ¥{wood_labor:,.2f} |\n" + f"| 油漆人工/饰面加工({s.get('finish_hours', 0):.2f}小时) | ¥{finish_labor:,.2f} |\n" + f"| 包装人工({s.get('packaging_hours', 0):.2f}小时) | ¥{packaging_labor:,.2f} |\n" + f"| 制造费用 | ¥{s.get('overhead', 0):,.2f} |\n" + f"| 包装材料({s.get('packaging_name', '')}) | ¥{s.get('packaging_cost', 0):,.2f} |\n" + f"| **总成本中值** | **¥{s.get('total_mid', 0):,.2f}** |\n\n" + f"成本区间:**¥{s.get('total_low', 0):,.2f}~¥{s.get('total_high', 0):,.2f}**。\n\n" + + factory_section + + wholesale_section + + preview_section + + production_drawing_section + + joinery_section + + "#### 完整部件 BOM\n\n" + "| 配件图 | 编号 | 部件 | 数量 | 单件尺寸(mm) | 材质 | 材料成本中值 |\n| --- | --- | --- | ---: | --- | --- | ---: |\n" + + "\n".join(bom_rows) + + "\n\n" + + ( + "#### 五金与安装配件 BOM\n\n" + "| 五金/配件 | 参考数量 | 校准单价 | 本次金额 | 样本覆盖 |\n| --- | ---: | ---: | ---: | ---: |\n" + + "\n".join(hardware_rows) + + f"\n\n> {hardware_bom.get('note', '')}\n\n" + if hardware_rows + else "" + ) + + history_section + + ( + "#### 工艺路线与人工\n\n" + "| 工序 | 工种 | 单件计费工时 | 小时费率 | 单件人工费 |\n| --- | --- | ---: | ---: | ---: |\n" + + "\n".join(process_rows) + + "\n\n" + if process_rows + else "" + ) + + ( + "#### 金属工艺成本\n\n" + "| 工序 | 物理计价驱动 | 数量 | 单件成本中值 |\n| --- | --- | ---: | ---: |\n" + + "\n".join(metal_process_rows) + + f"\n\n> {metal_costing.get('rule', '')}\n\n" + if metal_process_rows + else "" + ) + + ( + "#### 包装 BOM\n\n" + "| 包材 | 需求量 | 单价 | 损耗 | 成本 |\n| --- | ---: | ---: | ---: | ---: |\n" + + "\n".join(package_rows) + + "\n\n" + if package_rows + else "" + ) + + ( + "#### 制造费用资源池\n\n" + "| 资源池 | 成本驱动 | 金额 |\n| --- | --- | ---: |\n" + + "\n".join(manufacturing_rows) + + f"\n\n> {manufacturing.get('rule', '')}\n\n" + if manufacturing_rows + else "" + ) + + "#### 饰面/油漆与包装核算\n\n" + f"- **{s.get('finish_name', '')}工艺:** {finish_detail};饰面/油漆综合成本¥{finish_total:,.2f}。\n" + + panel_note + + metal_note + + f"- **包装:** {packaging_explanation};" + f"包装材料¥{s.get('packaging_cost', 0):,.2f}+包装人工¥{packaging_labor:,.2f}=" + f"包装综合成本¥{packaging_total:,.2f}。\n\n" + "```openclaw-actions\n" + + json.dumps( + { + "openclaw_actions": True, + "type": "quick_replies", + "title": "继续调整", + "actions": [ + {"label": "下载带图BOM", "message": "【技能路由:ai-solid-wood-cost】查看这个项目的完整部件BOM和成本明细", "intent": "view_bom"}, + {"label": "更换材质重算", "message": "【技能路由:ai-solid-wood-cost】更换这个项目的部件材质并重新计算", "intent": "change_material"}, + {"label": "修改油漆工艺", "message": "【技能路由:ai-solid-wood-cost】修改这个项目的油漆工艺并重新计算", "intent": "change_finish"}, + {"label": "修改包装方式", "message": "【技能路由:ai-solid-wood-cost】修改这个项目的包装方式", "intent": "change_packaging"}, + ] + ( + [{"label": "修改金属规格", "message": "【技能路由:ai-solid-wood-cost】修改这个项目的金属部件、规格和工艺", "intent": "change_metal"}] + if metal_costing.get("material_lines") else [] + ), + }, + ensure_ascii=False, + indent=2, + ) + + "\n```" + ) + + @staticmethod + def _preview_data_url(path: object) -> str: + preview_path = pathlib.Path(str(path or "")) + if not preview_path.is_file(): + return "" + with PILImage.open(preview_path) as image: + image = image.convert("RGB") + image.thumbnail((1280, 1280)) + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=82, optimize=True) + return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + + @staticmethod + def _asset_review_data_url(item: dict) -> str: + """Create a bounded review image while keeping the original asset intact.""" + value = str(item.get("value") or "") + try: + if item.get("kind") == "image_base64" and value.startswith("data:image/"): + raw = base64.b64decode(value.split(",", 1)[1]) + elif item.get("kind") == "image_url" and re.match(r"^https?://", value, re.I): + request = urllib.request.Request(value, headers={"User-Agent": "dalongxia-cad-review/1.0"}) + with urllib.request.urlopen(request, timeout=25) as response: + raw = response.read(15 * 1024 * 1024 + 1) + if len(raw) > 15 * 1024 * 1024: + return value + else: + return "" + with PILImage.open(io.BytesIO(raw)) as source: + image = source.convert("RGB") + # CAD review now receives each source image independently in + # addition to the contact sheet. Preserve enough resolution for + # drawer counts, shallow reveals and small dimension marks. + max_side = 1920 + image.thumbnail((max_side, max_side), PILImage.Resampling.LANCZOS) + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=72, optimize=True) + return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + except (OSError, ValueError, urllib.error.URLError, TimeoutError): + # A public URL can still be consumed directly by the model gateway. + return value if re.match(r"^https?://|^data:image/", value, re.I) else "" + + @classmethod + def _dimension_focus_sheet_data_url(cls, item: dict) -> str: + """Enlarge overlapping source quadrants without inventing OCR values. + + This is a generic pixel operation used only after a full-image pass + omits an overall axis. It does not choose a number or a product region + semantically; the vision model must still inspect the original labels + and dimension-line endpoints. + """ + review_url = cls._asset_review_data_url(item) + if not review_url.startswith("data:image/"): + return "" + try: + raw = base64.b64decode(review_url.split(",", 1)[1]) + with PILImage.open(io.BytesIO(raw)) as source: + image = source.convert("RGB") + width, height = image.size + if width < 64 or height < 64: + return "" + span_x, span_y = max(32, round(width * 0.62)), max(32, round(height * 0.62)) + boxes = [ + (0, 0, span_x, span_y), + (width - span_x, 0, width, span_y), + (0, height - span_y, span_x, height), + (width - span_x, height - span_y, width, height), + ] + cell, label_height = 900, 44 + sheet = PILImage.new("RGB", (cell * 2, (cell + label_height) * 2), "white") + draw = PILImageDraw.Draw(sheet) + font = cls._thumbnail_font(25, bold=True) + for index, box in enumerate(boxes): + pane = image.crop(box) + pane.thumbnail((cell - 16, cell - 16), PILImage.Resampling.LANCZOS) + x0 = (index % 2) * cell + y0 = (index // 2) * (cell + label_height) + draw.rectangle((x0, y0, x0 + cell - 1, y0 + label_height - 1), fill="#E8EDF3") + draw.text((x0 + 14, y0 + 7), f"原始尺寸图放大区域 {index + 1}", fill="#152033", font=font) + sheet.paste(pane, (x0 + (cell - pane.width) // 2, y0 + label_height + (cell - pane.height) // 2)) + buffer = io.BytesIO() + sheet.save(buffer, format="JPEG", quality=88, optimize=True) + return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + except (OSError, ValueError): + return "" + + @classmethod + def _dimension_annotation_focus_data_url(cls, item: dict) -> str: + """Extract high-chroma red dimension marks as a pixel-only aid. + + Many supplier drawings use red dimensions over pale product renders. + Compression can make small decimal labels unreadable to the vision + model. This deterministic mask contains no OCR result and therefore + cannot inject an answer; black or monochrome drawings simply return no + auxiliary image and keep the normal source path. + """ + review_url = cls._asset_review_data_url(item) + if not review_url.startswith("data:image/"): + return "" + try: + raw = base64.b64decode(review_url.split(",", 1)[1]) + with PILImage.open(io.BytesIO(raw)) as source: + image = source.convert("RGB") + mask = PILImage.new("L", image.size, 255) + source_pixels = image.load() + mask_pixels = mask.load() + matched = 0 + for y in range(image.height): + for x in range(image.width): + red, green, blue = source_pixels[x, y] + if red >= 105 and red - green >= 28 and red - blue >= 28: + mask_pixels[x, y] = 0 + matched += 1 + if matched < max(24, image.width * image.height // 25000): + return "" + max_side = max(image.size) + if max_side < 2200: + scale = min(3.0, 2200 / max_side) + mask = mask.resize( + (round(mask.width * scale), round(mask.height * scale)), + PILImage.Resampling.NEAREST, + ) + buffer = io.BytesIO() + mask.convert("RGB").save(buffer, format="PNG", optimize=True) + return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + except (OSError, ValueError): + return "" + + @classmethod + def _cad_review_contact_sheet(cls, source_assets: list[dict], built: dict) -> str: + """Combine four references and two CAD views into one labelled image.""" + panes: list[tuple[str, PILImage.Image]] = [] + role_labels = cls.ROLE_LABELS + try: + for item in source_assets[:4]: + review_url = cls._asset_review_data_url(item) + if not review_url: + continue + if review_url.startswith("data:image/"): + raw = base64.b64decode(review_url.split(",", 1)[1]) + else: + request = urllib.request.Request(review_url, headers={"User-Agent": "dalongxia-cad-review/1.0"}) + with urllib.request.urlopen(request, timeout=25) as response: + raw = response.read(15 * 1024 * 1024 + 1) + if len(raw) > 15 * 1024 * 1024: + continue + with PILImage.open(io.BytesIO(raw)) as image: + panes.append( + ( + f"原始图 asset_id={item.get('asset_id') or 0} " + f"{role_labels.get(str(item.get('role') or ''), '产品参考图')}", + image.convert("RGB").copy(), + ) + ) + for label, path in ( + ("CAD组装图", built.get("assembled_preview_path")), + ("CAD爆炸图", built.get("preview_path")), + ): + preview_path = pathlib.Path(str(path or "")) + if preview_path.is_file(): + with PILImage.open(preview_path) as image: + panes.append((label, image.convert("RGB").copy())) + if len(panes) < 3: + return "" + columns = 2 + cell_width, image_height, label_height = 960, 690, 48 + rows = math.ceil(len(panes) / columns) + sheet = PILImage.new("RGB", (columns * cell_width, rows * (image_height + label_height)), "#F4F6F8") + draw = PILImageDraw.Draw(sheet) + font = cls._thumbnail_font(28, bold=True) + for index, (label, pane) in enumerate(panes): + pane.thumbnail((cell_width - 24, image_height - 20), PILImage.Resampling.LANCZOS) + x0 = (index % columns) * cell_width + y0 = (index // columns) * (image_height + label_height) + draw.rectangle((x0, y0, x0 + cell_width - 1, y0 + label_height - 1), fill="#E5EAF0") + draw.text((x0 + 18, y0 + 7), label, fill="#172033", font=font) + x = x0 + (cell_width - pane.width) // 2 + y = y0 + label_height + (image_height - pane.height) // 2 + sheet.paste(pane, (x, y)) + buffer = io.BytesIO() + sheet.save(buffer, format="JPEG", quality=78, optimize=True) + return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + except (OSError, ValueError, urllib.error.URLError, TimeoutError): + return "" + + def _deterministic_cad_review_issues(self, analysis: dict) -> list[dict]: + components = analysis.get("components") or [] + issues: list[dict] = [] + overall = analysis.get("overall_dimensions_mm") or {} + overall_dims = { + axis: max(0.0, float(overall.get(axis) or 0.0)) + for axis in ("x", "y", "z") + } + + def add_geometry_issue(code: str, component: str, description: str, evidence: str) -> None: + issues.append( + { + "code": code, + "severity": "blocking", + "component": component, + "description": description, + "evidence": evidence, + "suggested_question": "请修正该部件尺寸/位置后重新生成CAD", + "requires_user_confirmation": False, + "confidence": 1.0, + "source": "deterministic_geometry_gate", + } + ) + + door_count = self._door_panel_count(components) + expected_door_count = len((analysis.get("door_dimensions_mm") or {}).get("leaf_widths") or []) + schedule_facade_conflicts = self._schedule_conflicting_facades( + list(components), + list((analysis.get("dimension_schedule") or {}).get("rows") or []), + overall_dims, + ) + for component in schedule_facade_conflicts: + add_geometry_issue( + "front_facade_duplicate_with_doors", + str(component.get("name") or component.get("part_id") or "前立面件"), + "尺寸表已单独列出柜门,但CAD/BOM仍存在未列入尺寸表的大面积前立面件", + "前立面件与柜门在产品正面覆盖范围重叠,会导致材料、油漆和人工重复计费", + ) + if bool(analysis.get("has_doors")) and door_count <= 0: + add_geometry_issue( + "door_part_missing", + "柜门", + "原图确认有柜门,但CAD/BOM没有任何柜门部件", + "has_doors=true且door_count=0,属于结构矛盾", + ) + if expected_door_count and door_count != expected_door_count: + add_geometry_issue( + "door_quantity_mismatch", + "柜门", + f"尺寸图标注{expected_door_count}扇门,CAD/BOM识别为{door_count}扇", + "门片宽度标注数量必须和柜门实例数量一致", + ) + door_instances = [item for item in self._component_instances(analysis) if self._is_door_panel({"name": item.get("part_name")})] + side_instances = [ + item for item in self._component_instances(analysis) + if any(token in str(item.get("part_name") or "").lower() for token in ("侧板", "side panel")) + and "抽屉" not in str(item.get("part_name") or "") + ] + if door_instances and len(side_instances) >= 2 and overall_dims["x"] > 0: + side_thickness = max(float((item.get("dimensions_mm") or {}).get("x") or 0.0) for item in side_instances) + opening_left = side_thickness + opening_right = overall_dims["x"] - side_thickness + door_left = min(float((item.get("assembled_position_mm") or {}).get("x") or 0.0) for item in door_instances) + door_right = max( + float((item.get("assembled_position_mm") or {}).get("x") or 0.0) + + float((item.get("dimensions_mm") or {}).get("x") or 0.0) + for item in door_instances + ) + opening_gap = max(0.0, opening_right - opening_left - sum( + float((item.get("dimensions_mm") or {}).get("x") or 0.0) for item in door_instances + )) + fit_tolerance = max(5.0, min(15.0, overall_dims["x"] * 0.0125)) + if ( + door_right > opening_right + fit_tolerance + or door_left < opening_left - fit_tolerance + or opening_gap > fit_tolerance * 2 + ): + add_geometry_issue( + "door_opening_fit_invalid", "柜门", + "柜门在左右侧板内口中的装配位置或总宽不成立", + f"门片范围{door_left:.1f}-{door_right:.1f}mm,内口{opening_left:.1f}-{opening_right:.1f}mm", + ) + if str(analysis.get("door_mechanism") or "") == "hinged" and door_count <= 0: + add_geometry_issue( + "hinged_door_missing", + "平开门", + "已识别为平开门,但CAD中没有可安装铰链的门板", + "door_mechanism=hinged必须至少存在1件柜门", + ) + + # Role-aware axis checks are deliberately independent of the vision + # model. The product coordinate contract is X=width, Y=depth, + # Z=height. A local board-axis interpretation produces a valid box but + # an impossible cabinet, so it must never reach the costing stage. + for component in components: + name = str(component.get("name") or component.get("part_id") or "部件") + key = self._component_key(component) + dims = { + axis: max(0.0, float((component.get("dimensions_mm") or {}).get(axis) or 0.0)) + for axis in ("x", "y", "z") + } + is_drawer = "抽屉" in key or "drawer" in key + if not is_drawer and (self._is_shelf_panel(component) or any(token in key for token in ("顶板", "柜体底板", "柜底板", "top panel", "bottom panel"))): + if dims["z"] > min(dims["x"], dims["y"]) + 1e-6: + add_geometry_issue( + "horizontal_panel_axis_invalid", name, + f"水平板件轴向错误:{dims['x']:.0f}×{dims['y']:.0f}×{dims['z']:.0f}mm", + "顶/底/层板的Z轴应为板厚,Y轴应为柜深", + ) + if overall_dims["y"] and dims["y"] > overall_dims["y"] * 1.05: + add_geometry_issue( + "horizontal_panel_depth_out_of_bounds", name, + f"板件深度{dims['y']:.0f}mm超过产品深度{overall_dims['y']:.0f}mm", + "水平板件不得沿Y轴突出整体柜深", + ) + if not is_drawer and any(token in key for token in ("侧板", "side panel", "left side", "right side")): + if dims["x"] > min(dims["y"], dims["z"]) + 1e-6: + add_geometry_issue( + "side_panel_axis_invalid", name, + f"侧板轴向错误:{dims['x']:.0f}×{dims['y']:.0f}×{dims['z']:.0f}mm", + "侧板X轴应为板厚,Y轴为柜深,Z轴为柜高", + ) + if overall_dims["y"] and dims["y"] > overall_dims["y"] * 1.05: + add_geometry_issue( + "side_panel_depth_out_of_bounds", name, + f"侧板深度{dims['y']:.0f}mm超过产品深度{overall_dims['y']:.0f}mm", + "侧板Y轴与产品深度冲突", + ) + if self._is_door_panel(component) or self._is_back_panel(component): + if dims["y"] > min(dims["x"], dims["z"]) + 1e-6: + add_geometry_issue( + "facade_panel_axis_invalid", name, + f"立面板件轴向错误:{dims['x']:.0f}×{dims['y']:.0f}×{dims['z']:.0f}mm", + "柜门/背板Y轴应为板厚,X/Z为宽/高", + ) + + instances = self._component_instances(analysis) + tolerance = max(5.0, min(20.0, max(overall_dims.values(), default=0.0) * 0.015)) + seen_boxes: dict[tuple[float, ...], dict] = {} + for instance in instances: + dims = instance.get("dimensions_mm") or {} + origin = instance.get("assembled_position_mm") or {} + if all(overall_dims[axis] > 0 for axis in ("x", "y", "z")) and any( + float(origin.get(axis) or 0.0) < -tolerance + or float(origin.get(axis) or 0.0) + float(dims.get(axis) or 0.0) > overall_dims[axis] + tolerance + for axis in ("x", "y", "z") + ): + add_geometry_issue( + "assembled_part_out_of_bounds", + str(instance.get("part_name") or instance.get("part_id") or "部件"), + "装配位置超出产品整体边界", + f"位置{origin},尺寸{dims},整体{overall_dims}", + ) + box_key = tuple( + round(float(value), 3) + for axis in ("x", "y", "z") + for value in (origin.get(axis) or 0.0, dims.get(axis) or 0.0) + ) + previous = seen_boxes.get(box_key) + if previous and previous.get("part_id") != instance.get("part_id"): + add_geometry_issue( + "assembled_part_exact_overlap", + f"{previous.get('part_name') or previous.get('part_id')}/{instance.get('part_name') or instance.get('part_id')}", + "两个不同部件在组装CAD中完全重合", + f"相同位置{origin}与尺寸{dims}", + ) + else: + seen_boxes[box_key] = instance + for missing_part in self._cabinet_carcass_missing(analysis): + issues.append( + { + "code": "cabinet_carcass_missing", + "severity": "blocking", + "component": missing_part, + "description": f"柜体BOM缺少成立所必需的{missing_part}", + "evidence": "基础柜体必须至少包含顶板、左右侧板和柜体底板", + "suggested_question": f"请确认{missing_part}的尺寸,或同意按整体尺寸补齐后重新生成CAD", + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "deterministic", + } + ) + drawer_count = int(analysis.get("drawer_count") or self._drawer_front_count(components)) + if drawer_count: + for item in components: + if self._is_drawer_composite(item): + issues.append( + { + "code": "drawer_composite_not_split", + "severity": "blocking", + "component": str(item.get("name") or "抽屉箱体"), + "description": "抽屉箱体仍是组合占位件,尚未拆成可制造的独立板件", + "evidence": "CAD/BOM中不能把抽屉箱体同时当作另一块抽屉面板", + "suggested_question": "请重新拆分抽屉面板、左右帮板、后帮板和底板", + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "deterministic", + } + ) + checks = { + "抽屉左帮板": ("抽屉左帮", "drawer left"), + "抽屉右帮板": ("抽屉右帮", "drawer right"), + "抽屉后帮板": ("抽屉后帮", "抽背", "drawer back"), + "抽屉底板": ("抽屉底板", "抽底", "drawer bottom"), + } + for label, tokens in checks.items(): + count = sum( + max(1, int(item.get("quantity") or 1)) + for item in components + if any(token in str(item.get("name") or "").lower() for token in tokens) + ) + if count != drawer_count: + is_missing = count < drawer_count + issues.append( + { + "code": "drawer_part_missing" if is_missing else "drawer_part_excess", + "severity": "blocking", + "component": label, + "description": f"有{drawer_count}个抽屉,但{label}识别为{count}件,应为{drawer_count}件", + "evidence": "每个抽屉必须且只能有1件对应的左帮板、右帮板、后帮板和底板", + "suggested_question": f"请核对{label},CAD和BOM数量必须改为{drawer_count}件", + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "deterministic", + } + ) + if str(analysis.get("door_mechanism") or "") == "sliding" and any( + "铰链" in str(item.get("name") or "") for item in analysis.get("recognized_hardware") or [] + ): + issues.append( + { + "code": "sliding_door_hinge_conflict", + "severity": "blocking", + "component": "推拉门五金", + "description": "推拉门BOM中仍存在铰链", + "evidence": "door_mechanism=sliding与铰链冲突", + "suggested_question": "请确认是推拉门还是平开门", + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "deterministic", + } + ) + if str(analysis.get("door_mechanism") or "") == "sliding" and not any( + any(token in str(item.get("name") or "").lower() for token in ("轨", "滑轮", "track", "roller")) + for item in analysis.get("recognized_hardware") or [] + ): + issues.append( + { + "code": "sliding_track_missing", + "severity": "blocking", + "component": "移门轨道与滑轮", + "description": "推拉门结构未记录成立所必需的上下轨道和滑轮", + "evidence": "door_mechanism=sliding但recognized_hardware无轨道/滑轮", + "suggested_question": "请确认移门轨道与滑轮规格", + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "deterministic", + } + ) + topology = analysis.get("user_topology_constraints") or {} + topology_expected = topology.get("expected_counts") or {} + actual_topology_counts = { + "doors": self._door_panel_count(components), + "drawers": self._drawer_front_count(components), + "shelves": sum( + max(1, int(item.get("quantity") or 1)) + for item in components + if self._is_shelf_panel(item) + ), + } + topology_labels = {"doors": "柜门", "drawers": "抽屉", "shelves": "层板"} + for key, raw_expected in topology_expected.items(): + if key not in actual_topology_counts: + continue + expected_count = max(0, int(self._float(raw_expected, 0) or 0)) + actual_count = actual_topology_counts[key] + if actual_count != expected_count: + add_geometry_issue( + f"user_topology_{key}_count_mismatch", + topology_labels[key], + f"用户已确认{expected_count}件,CAD/BOM实际为{actual_count}件", + "用户明确确认的拓扑数量优先于图片模型推测", + ) + for required_name in topology.get("required_parts") or []: + if not any(str(required_name) in str(item.get("name") or "") for item in components): + add_geometry_issue( + "user_required_part_missing", + str(required_name), + f"用户已明确存在{required_name},CAD/BOM仍未生成该部件", + "用户对话中的明确部件清单必须逐件落入CAD与BOM", + ) + joinery = analysis.get("production_joinery") or {} + if joinery.get("schema") in {"dalongxia.production-joinery.v1", "dalongxia.production-joinery.v2"} and joinery.get("applicable"): + totals = joinery.get("totals") or {} + for validation_issue in joinery.get("validation_issues") or []: + if str(validation_issue.get("severity") or "") != "blocking": + continue + issues.append( + { + "code": str(validation_issue.get("code") or "joinery_geometry_invalid"), + "severity": "blocking", + "component": str(validation_issue.get("part_id") or "生产连接结构"), + "description": str(validation_issue.get("message") or "生产孔槽或连接结构未通过确定性校验"), + "evidence": "CAD孔槽、部件尺寸与装配关系的确定性几何校验", + "suggested_question": str(validation_issue.get("suggested_question") or "请修正连接结构后重新生成CAD"), + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "deterministic_joinery_gate", + } + ) + feature_holes = sum( + len(((item.get("manufacturing_features") or {}).get("holes") or [])) + * max(1, int(item.get("quantity") or 1)) + for item in components + ) + feature_grooves = sum( + len(((item.get("manufacturing_features") or {}).get("grooves") or [])) + * max(1, int(item.get("quantity") or 1)) + for item in components + ) + expected_holes = int(totals.get("holes") or 0) + expected_grooves = int(totals.get("grooves") or 0) + if feature_holes != expected_holes or feature_grooves != expected_grooves: + issues.append( + { + "code": "joinery_feature_count_mismatch", + "severity": "blocking", + "component": "CAD生产孔槽", + "description": f"连接方案记录孔/槽{expected_holes}/{expected_grooves},CAD部件实际为{feature_holes}/{feature_grooves}", + "evidence": "生产连接方案与CAD几何特征数量不一致", + "suggested_question": "请重新生成生产孔槽后再报价", + "requires_user_confirmation": False, + "confidence": 1.0, + "source": "deterministic", + } + ) + back_groove = joinery.get("back_panel_groove") or {} + if any(self._is_back_panel(item) for item in components) and not back_groove.get("enabled"): + issues.append( + { + "code": "back_panel_groove_missing", + "severity": "blocking", + "component": "背板连接槽", + "description": "产品有背板,但CAD未生成背板嵌入槽", + "evidence": "背板BOM存在且背板槽计数为0", + "suggested_question": "请补齐侧板、顶板和底板的背板槽", + "requires_user_confirmation": False, + "confidence": 1.0, + "source": "deterministic", + } + ) + drawer_groove = joinery.get("drawer_bottom_groove") or {} + if drawer_count and (not drawer_groove.get("enabled") or int(drawer_groove.get("count") or 0) < drawer_count * 4): + issues.append( + { + "code": "drawer_bottom_groove_missing", + "severity": "blocking", + "component": "抽屉底板连接槽", + "description": f"{drawer_count}个抽屉应至少有{drawer_count * 4}道底板槽,当前为{int(drawer_groove.get('count') or 0)}道", + "evidence": "抽屉底板必须由左右帮、前板和后帮四边承托", + "suggested_question": "请补齐抽屉四周底板槽后再报价", + "requires_user_confirmation": False, + "confidence": 1.0, + "source": "deterministic", + } + ) + overall_max = max((float(overall.get(axis) or 0.0) for axis in ("x", "y", "z")), default=0.0) + for component in components: + dims = component.get("dimensions_mm") or {} + values = [float(dims.get(axis) or 0.0) for axis in ("x", "y", "z")] + if any(value <= 0 for value in values) or (overall_max > 0 and max(values) > overall_max * 1.15): + issues.append( + { + "code": "component_geometry_outlier", + "severity": "blocking", + "component": str(component.get("name") or component.get("part_id") or "部件"), + "description": f"部件尺寸{values}与产品外形尺寸不相容", + "evidence": "CAD生成前确定性几何校验", + "suggested_question": "请提供该部件的实际尺寸", + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "deterministic", + } + ) + return issues[:20] + + @classmethod + def _ai_cad_issue_is_contradicted(cls, analysis: dict, issue: dict) -> bool: + """Reject AI review claims disproved by the dimension ledger and CAD BOM. + + The multimodal reviewer is an advisory layer. It must not overrule an + exact, high-confidence component schedule by confusing product axes or + overlooking a visible CAD part. Hardware is intentionally excluded: + a hinge listed in the costing BOM can still be absent from the actual + production CAD/vendor drilling template. + """ + text = " ".join( + str(issue.get(key) or "") + for key in ("code", "component", "description", "evidence") + ).lower() + components = list(analysis.get("components") or []) + schedule_rows = list((analysis.get("dimension_schedule") or {}).get("rows") or []) + joinery = analysis.get("production_joinery") or {} + # Never block on a reviewer claiming that equivalent centimetre and + # millimetre annotations disagree (for example 15CM vs 150mm). + # Normalize the explicit unit-bearing values before any semantic test. + centimetres = [ + float(value) * 10.0 + for value in re.findall(r"(? 0 + ] + # The user's explicit thickness selection is authoritative. A + # reviewer must not reinterpret a vertical 15CM drawer-height + # arrow as a 15mm material thickness after the CAD was generated + # from uniform 12mm panels. + if confirmed and len(confirmed) == len(drawer_fronts) and all( + abs(float((item.get("dimensions_mm") or {}).get("y") or 0.0) - value) <= 0.01 + for item, value in zip(drawer_fronts, confirmed) + ): + return True + if ( + any(token in text for token in ("层板", "搁板", "层隔板", "shelf")) + and any(token in text for token in ("承托", "支撑", "层板托", "support")) + and bool((joinery.get("shelf_support") or {}).get("enabled")) + ): + return True + overall = analysis.get("overall_dimensions_mm") or {} + instances = cls._component_instances(analysis) + missing_markers = ("缺少", "缺失", "遗漏", "未画", "未见", "没有", "missing", "absent", "not shown") + if any(marker in text for marker in missing_markers): + # Every component instance in this manifest is emitted by the same + # deterministic loop that creates the STEP/GLB. A part hidden by a + # door in the assembled thumbnail is therefore not absent from CAD. + for component in components: + name = str(component.get("name") or "").lower() + part_id = str(component.get("part_id") or "").lower() + if (name and name in text) or (part_id and part_id in text): + return True + if any(token in text for token in ("抽屉结构", "drawer structure", "drawer assembly")): + drawer_count = cls._drawer_front_count(components) + if drawer_count and all( + sum( + max(1, int(item.get("quantity") or 1)) + for item in components if cls._drawer_box_role(item) == role + ) == drawer_count + for role in ("left", "right", "back", "bottom") + ): + return True + topology = analysis.get("user_topology_constraints") or {} + segment_count = max(0, int(cls._float(topology.get("side_segments_per_side"), 0) or 0)) + if ( + segment_count > 1 + and any(token in text for token in ("侧板", "side panel")) + and any(token in text for token in ("分段", "接缝", "连续侧板", "segment", "seam", "continuous")) + ): + def segmented_side_instances(side_tokens: tuple[str, ...]) -> list[dict]: + return [ + item for item in components + if "抽屉" not in cls._component_key(item) + and any(token in cls._component_key(item) for token in side_tokens) + and item.get("module_segment_index") is not None + ] + + left_segments = segmented_side_instances(("左侧板", "left side")) + right_segments = segmented_side_instances(("右侧板", "right side")) + left_indexes = {int(item.get("module_segment_index")) for item in left_segments} + right_indexes = {int(item.get("module_segment_index")) for item in right_segments} + if len(left_indexes) >= segment_count and len(right_indexes) >= segment_count: + return True + estimation_allowed = bool(analysis.get("estimation_consent")) or "预估" in str(topology.get("source_text") or "") + if any(token in text for token in ("抽屉底板", "抽屉左帮", "抽屉右帮", "drawer bottom", "drawer side")): + spatial_markers = ( + "位置", "装配", "穿出", "越界", "外部", "冲突", "干涉", "重叠", + "position", "assembly", "outside", "out of bounds", "collision", "interference", "overlap", + ) + outside_markers = ("穿出柜体", "柜体外部", "越出柜体", "outside cabinet", "out of bounds") + if any(token in text for token in outside_markers): + drawer_instances = [ + item for item in instances + if cls._drawer_box_role({"name": item.get("part_name")}) in {"left", "right", "back", "bottom"} + ] + tolerance = 0.5 + if drawer_instances and all( + float((item.get("assembled_position_mm") or {}).get(axis) or 0.0) >= -tolerance + and float((item.get("assembled_position_mm") or {}).get(axis) or 0.0) + + float((item.get("dimensions_mm") or {}).get(axis) or 0.0) + <= float(overall.get(axis) or 0.0) + tolerance + for item in drawer_instances for axis in ("x", "y", "z") + ): + return True + return False + # Valid component depth can disprove a depth estimate, but it says + # nothing about position, collision or assembly. Those claims + # must survive for correction unless exact CAD bounds above prove + # the specific "outside cabinet" assertion false. + if any(token in text for token in spatial_markers): + if cls._drawer_cad_geometry_consistent(analysis): + return True + return False + if not estimation_allowed: + return False + cabinet_depth = float(overall.get("y") or 0.0) + drawer_depths = [ + float((item.get("dimensions_mm") or {}).get("y") or 0.0) + for item in components + if cls._drawer_box_role(item) in {"left", "right", "bottom"} + ] + if cabinet_depth > 0 and drawer_depths and all(0 < value < cabinet_depth for value in drawer_depths): + return True + overall_axis_tokens = { + "x": ("整体宽", "柜体宽", "overall width"), + "y": ("整体深", "柜体深", "overall depth"), + "z": ("整体高", "柜体高", "overall height"), + } + for axis, tokens in overall_axis_tokens.items(): + if not any(token in text for token in tokens) or not instances: + continue + low = min(float((item.get("assembled_position_mm") or {}).get(axis) or 0.0) for item in instances) + high = max( + float((item.get("assembled_position_mm") or {}).get(axis) or 0.0) + + float((item.get("dimensions_mm") or {}).get(axis) or 0.0) + for item in instances + ) + target = float(overall.get(axis) or 0.0) + if target > 0 and abs((high - low) - target) <= max(5.0, target * 0.015): + return True + if ( + any(token in text for token in ("柜门", "门板", "door")) + and any(token in text for token in ("位置", "装配", "干涉", "间隙", "开合", "position", "collision", "clearance")) + ): + door_instances = [item for item in instances if cls._is_door_panel({"name": item.get("part_name")})] + side_instances = [ + item for item in instances + if any(token in str(item.get("part_name") or "").lower() for token in ("侧板", "side panel")) + and "抽屉" not in str(item.get("part_name") or "") + ] + if door_instances and len(side_instances) >= 2: + side_thickness = max(float((item.get("dimensions_mm") or {}).get("x") or 0.0) for item in side_instances) + opening_left = side_thickness + opening_right = float(overall.get("x") or 0.0) - side_thickness + door_left = min(float((item.get("assembled_position_mm") or {}).get("x") or 0.0) for item in door_instances) + door_right = max( + float((item.get("assembled_position_mm") or {}).get("x") or 0.0) + + float((item.get("dimensions_mm") or {}).get("x") or 0.0) + for item in door_instances + ) + tolerance = max(5.0, float(overall.get("x") or 0.0) * 0.0125) + if door_left >= opening_left - tolerance and door_right <= opening_right + tolerance: + return True + role_tokens = { + "top": ("顶板", "top panel"), + "bottom": ("柜体底板", "底板", "bottom panel"), + "side": ("侧板", "side panel"), + "shelf": ("层板", "搁板", "层隔板", "shelf"), + "door": ("柜门", "门板", "door"), + "back": ("背板", "back panel"), + "leg": ("支腿", "柜腿", "leg"), + "drawer_front": ("抽屉面", "drawer front"), + "drawer_left": ("抽屉左帮", "抽屉左侧板", "drawer left"), + "drawer_right": ("抽屉右帮", "抽屉右侧板", "drawer right"), + "drawer_back": ("抽屉后帮", "抽屉后板", "drawer back"), + "drawer_bottom": ("抽屉底", "drawer bottom"), + } + quantity_markers = ("数量", "件数", "多画", "少画", "quantity", "count") + dimension_markers = ("尺寸", "轴向", "宽", "深", "高", "厚", "dimension", "axis", "width", "depth", "height") + for role, tokens in role_tokens.items(): + if not any(token in text for token in tokens): + continue + actual = [item for item in components if cls._component_schedule_role(item) == role] + actual_quantity = sum(max(1, int(item.get("quantity") or 1)) for item in actual) + schedule = [row for row in schedule_rows if str(row.get("role") or "") == role] + if any(marker in text for marker in missing_markers) and actual_quantity > 0: + return True + if schedule: + expected_quantity = sum(max(1, int(row.get("quantity") or 1)) for row in schedule) + if any(marker in text for marker in quantity_markers) and actual_quantity == expected_quantity: + return True + if any(marker in text for marker in dimension_markers): + expected_dims = [ + tuple(round(float((row.get("dims") or {}).get(axis) or 0.0), 3) for axis in ("x", "y", "z")) + for row in schedule + ] + actual_dims = [ + tuple(round(float((item.get("dimensions_mm") or {}).get(axis) or 0.0), 3) for axis in ("x", "y", "z")) + for item in actual + ] + if actual_dims and all(dims in expected_dims for dims in actual_dims): + return True + return False + + @classmethod + def _drawer_cad_geometry_consistent(cls, analysis: dict) -> bool: + """Verify a drawer box from the exact CAD manifest and groove data. + + This is intentionally stricter than checking that a panel is merely + smaller than the cabinet. Every drawer must have one front, left and + right side, back and bottom; the five parts must touch/contain each + other in assembled coordinates, remain inside the product envelope, + and all four retaining walls must contain a validated bottom groove. + """ + components = list(analysis.get("components") or []) + instances = cls._component_instances(analysis) + grouped: dict[str, list[dict]] = {role: [] for role in ("front", "left", "right", "back", "bottom")} + for item in instances: + role = cls._drawer_box_role({"name": item.get("part_name")}) + if role in grouped: + grouped[role].append(item) + drawer_count = len(grouped["front"]) + if drawer_count <= 0 or any(len(grouped[role]) != drawer_count for role in grouped): + return False + overall = analysis.get("overall_dimensions_mm") or {} + if any(float(overall.get(axis) or 0.0) <= 0 for axis in ("x", "y", "z")): + return False + tolerance = max(0.75, min(3.0, max(float(overall.get(axis) or 0.0) for axis in ("x", "y", "z")) * 0.002)) + + def start(item: dict, axis: str) -> float: + return float((item.get("assembled_position_mm") or {}).get(axis) or 0.0) + + def size(item: dict, axis: str) -> float: + return float((item.get("dimensions_mm") or {}).get(axis) or 0.0) + + def end(item: dict, axis: str) -> float: + return start(item, axis) + size(item, axis) + + drawer_instances = [item for role in grouped.values() for item in role] + if not all( + start(item, axis) >= -tolerance + and end(item, axis) <= float(overall.get(axis) or 0.0) + tolerance + for item in drawer_instances for axis in ("x", "y", "z") + ): + return False + + def order(item: dict) -> tuple[float, float]: + return ( + start(item, "z") + size(item, "z") / 2.0, + start(item, "x") + size(item, "x") / 2.0, + ) + + for role in grouped: + grouped[role].sort(key=order) + for index in range(drawer_count): + front = grouped["front"][index] + left = grouped["left"][index] + right = grouped["right"][index] + back = grouped["back"][index] + bottom = grouped["bottom"][index] + if not ( + start(bottom, "x") >= start(front, "x") - tolerance + and end(bottom, "x") <= end(front, "x") + tolerance + and start(bottom, "z") >= start(front, "z") - tolerance + and end(bottom, "z") <= end(front, "z") + tolerance + and abs(start(bottom, "y") - end(front, "y")) <= tolerance + and abs(end(left, "x") - start(bottom, "x")) <= tolerance + and abs(start(right, "x") - end(bottom, "x")) <= tolerance + and start(left, "y") <= start(bottom, "y") + tolerance + and end(left, "y") >= end(bottom, "y") - tolerance + and start(right, "y") <= start(bottom, "y") + tolerance + and end(right, "y") >= end(bottom, "y") - tolerance + and start(left, "z") <= start(bottom, "z") + tolerance + and end(left, "z") >= end(bottom, "z") - tolerance + and start(right, "z") <= start(bottom, "z") + tolerance + and end(right, "z") >= end(bottom, "z") - tolerance + and start(back, "x") <= start(bottom, "x") + tolerance + and end(back, "x") >= end(bottom, "x") - tolerance + and start(back, "y") <= end(bottom, "y") + tolerance + and end(back, "y") >= end(bottom, "y") - tolerance + and start(back, "z") <= start(bottom, "z") + tolerance + and end(back, "z") >= end(bottom, "z") - tolerance + ): + return False + retaining_roles = {"front", "left", "right", "back"} + for role in retaining_roles: + role_components = [item for item in components if cls._drawer_box_role(item) == role] + if not role_components or any( + not any( + str(feature.get("group") or "") == "drawer_bottom_groove" + and str(feature.get("validation_status") or "valid") == "valid" + for feature in ((item.get("manufacturing_features") or {}).get("grooves") or []) + ) + for item in role_components + ): + return False + return True + + @classmethod + def _cad_review_findings_complete(cls, raw: dict, source_assets: list[dict]) -> bool: + """Require positive evidence that every accepted source image was read.""" + if not source_assets: + return True + findings = raw.get("view_findings") + if not isinstance(findings, list): + return False + expected_ids = { + int(cls._float(item.get("asset_id"), 0) or 0) + for item in source_assets + if int(cls._float(item.get("asset_id"), 0) or 0) > 0 + } + returned_ids = { + int(cls._float(item.get("asset_id"), 0) or 0) + for item in findings + if isinstance(item, dict) and int(cls._float(item.get("asset_id"), 0) or 0) > 0 + } + if expected_ids: + return expected_ids.issubset(returned_ids) + expected_roles = {str(item.get("role") or "") for item in source_assets} + returned_roles = { + str(item.get("declared_role") or item.get("role") or "") + for item in findings if isinstance(item, dict) + } + return bool(expected_roles) and expected_roles.issubset(returned_roles) + + @classmethod + def _cad_review_view_issues( + cls, + analysis: dict, + raw: dict, + source_assets: list[dict], + ) -> list[dict]: + """Turn per-view disagreements into blocking, traceable review items.""" + expected_by_id = { + int(cls._float(item.get("asset_id"), 0) or 0): str(item.get("role") or "") + for item in source_assets + if int(cls._float(item.get("asset_id"), 0) or 0) > 0 + } + issues: list[dict] = [] + overall = analysis.get("overall_dimensions_mm") or {} + expected_counts = { + "drawers": max(0, int(cls._float(analysis.get("drawer_count"), 0) or 0)), + "doors": max(0, int(cls._float(analysis.get("door_count"), 0) or 0)), + } + topology_contract = analysis.get("user_topology_constraints") or {} + topology_expected = dict(topology_contract.get("expected_counts") or {}) if ( + str(topology_contract.get("source") or "").startswith("用户") + ) else {} + for finding in (raw.get("view_findings") or [])[:16]: + if not isinstance(finding, dict): + continue + asset_id = int(cls._float(finding.get("asset_id"), 0) or 0) + declared_role = expected_by_id.get( + asset_id, + str(finding.get("declared_role") or finding.get("role") or ""), + ) + observed_role = str(finding.get("observed_role") or "") + confidence = min(1.0, max(0.0, cls._float(finding.get("confidence"), 0.0) or 0.0)) + evidence = str(finding.get("evidence") or "逐图复核结果")[:200] + if ( + declared_role in cls.ROLES + and observed_role in cls.ROLES + and observed_role != declared_role + and confidence >= 0.80 + and not ( + declared_role == "dimension" + and ( + float(cls._float(finding.get("dimension_confidence"), 0.0) or 0.0) >= 0.50 + or any(token in evidence for token in ("尺寸", "标注", "箭头", "mm", "cm")) + ) + ) + ): + issues.append( + { + "code": "source_view_role_mismatch", + "severity": "info", + "component": cls.ROLE_LABELS.get(declared_role, "来源图片"), + "description": ( + f"图片原标记为{cls.ROLE_LABELS.get(declared_role, declared_role)}," + f"逐图复核判定为{cls.ROLE_LABELS.get(observed_role, observed_role)}" + ), + "evidence": evidence, + "suggested_question": "图片已在上传阶段通过逐张验收,本轮只记录复核模型的视角分歧", + "requires_user_confirmation": False, + "confidence": confidence, + "source": "multiview_per_image_gate", + } + ) + if bool(finding.get("complete_product_front_visible")) and declared_role == "front": + count_confidence = min( + 1.0, + max(0.0, cls._float(finding.get("count_confidence"), confidence) or confidence), + ) + visible_counts = finding.get("visible_counts") or {} + if count_confidence >= 0.80 and isinstance(visible_counts, dict): + for key, label in (("drawers", "抽屉"), ("doors", "柜门")): + # Explicit user topology is authoritative. A single + # appearance-model count is retained in view_findings + # for audit, but must not overrule that confirmation. + if key in topology_expected: + continue + observed_raw = visible_counts.get(key) + if observed_raw is None: + continue + observed = max(0, int(cls._float(observed_raw, 0) or 0)) + expected = expected_counts[key] + if observed != expected: + issues.append( + { + "code": f"source_{key}_count_mismatch", + "severity": "blocking", + "component": label, + "description": f"完整正面图可见{observed}个{label},CAD/BOM记录为{expected}个", + "evidence": evidence, + "suggested_question": f"请确认{label}数量,修正后重新生成CAD", + "requires_user_confirmation": True, + "confidence": count_confidence, + "source": "multiview_per_image_gate", + } + ) + if declared_role == "dimension": + dimension_confidence = min( + 1.0, + max(0.0, cls._float(finding.get("dimension_confidence"), confidence) or confidence), + ) + observed_dims = finding.get("overall_dimensions_mm") or {} + if dimension_confidence >= 0.80 and isinstance(observed_dims, dict): + observed_x = float(cls._float(observed_dims.get("x"), 0.0) or 0.0) + observed_y = float(cls._float(observed_dims.get("y"), 0.0) or 0.0) + expected_x = float(cls._float(overall.get("x"), 0.0) or 0.0) + expected_y = float(cls._float(overall.get("y"), 0.0) or 0.0) + observed_evidence = finding.get("dimension_evidence") or {} + observed_x_source = str((observed_evidence.get("x") or {}).get("source") or "").lower() + observed_y_source = str((observed_evidence.get("y") or {}).get("source") or "").lower() + semantic_xy_swap = bool( + re.search(r"深|depth", observed_x_source) + and re.search(r"宽|width", observed_y_source) + and observed_x > 0 + and observed_y > 0 + and expected_x > 0 + and expected_y > 0 + and abs(observed_x - expected_y) <= max(5.0, expected_y * 0.03) + and abs(observed_y - expected_x) <= max(5.0, expected_x * 0.03) + ) + for axis, label in (("x", "整体宽度"), ("y", "整体深度"), ("z", "整体高度")): + # The reviewer sometimes reports image axes instead of + # the fixed product contract X=width/Y=depth. When its + # pair is exactly the already-evidenced semantic swap, + # retain the corrected dimensions rather than asking + # the user to adjudicate the model's axis vocabulary. + if semantic_xy_swap and axis in {"x", "y"}: + continue + observed = float(cls._float(observed_dims.get(axis), 0.0) or 0.0) + expected = float(cls._float(overall.get(axis), 0.0) or 0.0) + tolerance = max(5.0, expected * 0.03) + if observed > 0 and expected > 0 and abs(observed - expected) > tolerance: + issues.append( + { + "code": f"source_overall_{axis}_mismatch", + "severity": "blocking", + "component": label, + "description": f"尺寸图逐图复核为{observed:.0f}mm,CAD/BOM采用{expected:.0f}mm", + "evidence": evidence, + "suggested_question": f"请确认{label}后重新生成CAD", + "requires_user_confirmation": True, + "confidence": dimension_confidence, + "source": "multiview_per_image_gate", + } + ) + # Dimension drawings often reveal that a tall furniture side + # is built from stacked modules. Treat an explicit seam/count + # observation as structure evidence: a single continuous side + # changes the cut list, nesting and flat-pack volume even when + # its outside silhouette looks similar. + segmentation_confidence = min( + 1.0, + max( + 0.0, + cls._float(finding.get("side_panel_segmentation_confidence"), 0.0) or 0.0, + ), + ) + observed_segments = max( + 0, + int(cls._float(finding.get("side_panel_segments_per_side"), 0) or 0), + ) + side_instances = sum( + max(1, int(item.get("quantity") or 1)) + for item in (analysis.get("components") or []) + if any(token in cls._component_key(item) for token in ("侧板", "side panel")) + and "抽屉" not in cls._component_key(item) + ) + actual_segments_per_side = max(1, int(math.ceil(side_instances / 2.0))) if side_instances else 0 + if ( + segmentation_confidence >= 0.80 + and observed_segments > 1 + and actual_segments_per_side < observed_segments + ): + heights = [ + round(float(value), 1) + for value in (finding.get("side_panel_segment_heights_mm") or []) + if cls._float(value, 0.0) + ][:8] + segment_note = "、".join(f"{value:g}mm" for value in heights) + issues.append( + { + "code": "source_side_panel_segmentation_mismatch", + "severity": "blocking", + "component": "左右侧板模块分段", + "description": ( + f"尺寸图显示每侧{observed_segments}段,CAD/BOM仅{actual_segments_per_side}段" + ), + "evidence": evidence + (f";分段高度{segment_note}" if segment_note else ""), + "suggested_question": "请确认侧板按图分段,或明确使用一整块连续侧板", + "requires_user_confirmation": True, + "confidence": segmentation_confidence, + "source": "multiview_per_image_gate", + } + ) + return issues + + def _review_cad_model(self, analysis: dict, built: dict, source_assets: list[dict] | None = None) -> dict: + deterministic = self._deterministic_cad_review_issues(analysis) + source_assets = [ + item for item in (source_assets or []) + if item.get("validation_status") == "accepted" + ] + compact_components = [ + { + "part_id": item.get("part_id"), + "name": item.get("name"), + "quantity": item.get("quantity"), + "dimensions_mm": item.get("dimensions_mm"), + "confidence": item.get("confidence"), + "confirmed_thickness_mm": item.get("confirmed_thickness_mm"), + "dimension_evidence": item.get("dimension_evidence"), + "manufacturing_features": { + "holes": len(((item.get("manufacturing_features") or {}).get("holes") or [])), + "grooves": len(((item.get("manufacturing_features") or {}).get("grooves") or [])), + }, + } + for item in (analysis.get("components") or [])[:40] + ] + joinery = analysis.get("production_joinery") or {} + compact_joinery = { + "schema": joinery.get("schema"), + "applicable": joinery.get("applicable"), + "totals": joinery.get("totals") or {}, + "shelf_support": joinery.get("shelf_support") or {}, + "leg_connection": joinery.get("leg_connection") or {}, + "validation_issues": list(joinery.get("validation_issues") or [])[:8], + "hardware_lines": [ + { + "name": item.get("name"), + "specification": item.get("specification"), + "quantity": item.get("quantity"), + "evidence": item.get("evidence"), + } + for item in (joinery.get("hardware_lines") or [])[:16] + ], + } + compact_instances = [ + { + "part_id": item.get("part_id"), + "name": item.get("part_name"), + "instance": item.get("instance_index"), + "dimensions_mm": item.get("dimensions_mm"), + "assembled_position_mm": item.get("assembled_position_mm"), + } + for item in self._component_instances(analysis)[:60] + ] + content: list[dict] = [ + { + "type": "text", + "text": ( + "你是家具CAD多模态结构复核工程师。下面先给原始正面/侧面/内部/尺寸图片,再给爆炸图和组装图。" + "必须逐项对比原始图片、尺寸标注、CAD和BOM,不得只审查CAD自身。爆炸图中部件分开是预期效果,不得将‘分开’本身判为异常。" + "检查:图片可见部件是否在CAD/BOM中遗漏,是否多画或错画;柜体是否有顶板、左右侧板、底板及图片可见隔板/搁板;" + "抽屉是否含抽面+左右帮+后帮+底板;移门是否有轨道/滑轮且没有铰链;开合方式和关键尺寸是否与原图冲突;" + "同时核对生产连接方案:顶/底板与侧板的连接孔是否成对成立、螺钉规格是否适配板厚、背板是否有连接槽、" + "每个抽屉底板是否由左右帮板、前板和后帮板的槽承托;孔槽数量必须和BOM/工艺驱动一致。" + "组装几何是否严重冲突或超出整体边界。只要组装图中看到板件横向穿出柜体、完全重合、轴向错乱、" + "柜门/层板/侧板位置不成立,必须返回ok=false、assembly_reasonable=false并给出blocking issue," + "不得因为BOM文字看似完整就判定通过。用户已授权的低置信尺寸可保留为预估,但结构件遗漏必须报告并要求确认。" + "CAD装配清单中的每个实例都由同一生成循环写入STEP/GLB;若组装缩略图被门板遮挡,不得声称清单中的内部件未画。" + "抽屉箱体深度小于柜体总深属于滑轨和后部间隙,不得要求抽屉底板深度等于柜体总深;" + "用户已授权预估的抽屉内部尺寸,不得拿没有指向关系的层板标注强行替代。" + "特别检查尺寸图中的模块拼接缝、侧板分段线和每段高度:连续外轮廓不等于一整块料;" + "若尺寸图明确显示上下模块接缝,而CAD/BOM用一整块连续侧板,必须返回blocking issue。" + "尺寸文字必须结合箭头方向和单位理解:竖向15CM是抽面高度150mm,不是板厚15mm;" + "若components含confirmed_thickness_mm,该值为用户后续明确选择,优先级高于图片中方向模糊的标注。" + "只返回JSON:" + '{"ok":true,"confidence":0.9,"issues":[{"code":"","severity":"blocking/warning/info",' + '"component":"","description":"","evidence":"","suggested_question":"",' + '"requires_user_confirmation":true,"confidence":0.8}],' + '"checks":{"source_images_consistent":true,"required_parts_complete":true,"assembly_reasonable":true,' + '"drawer_complete":true,"door_hardware_consistent":true,"no_obvious_duplicate":true,' + '"production_joinery_consistent":true},' + '"view_findings":[{"asset_id":1,"declared_role":"front","observed_role":"front",' + '"confidence":0.95,"complete_product_front_visible":true,"count_confidence":0.95,' + '"visible_counts":{"drawers":2,"doors":0,"shelves":5},' + '"drawer_layout":"vertical","shelf_layout":"upper_open","has_back_panel":false,' + '"overall_dimensions_mm":{"x":0,"y":0,"z":0},"dimension_confidence":0.0,' + '"side_panel_segments_per_side":1,"side_panel_segment_heights_mm":[],' + '"side_panel_segmentation_confidence":0.0,' + '"edge_profile_radius_mm":0,"edge_profile_confidence":0.0,' + '"evidence":"画面中可直接看到两个抽屉"}]}\n' + "issues最多6条;每条description/evidence/suggested_question各不超过60个汉字。不得复述完整BOM、不得输出分析过程。\n" + "view_findings必须逐张覆盖下方每个asset_id,不能只看六图总览;observed_role必须根据画面判断," + "不能照抄declared_role。只有完整正面确实可见时complete_product_front_visible才为true;" + "尺寸图能直接读出的整体宽深高写入overall_dimensions_mm,未标注的轴写0,禁止推测。\n" + + json.dumps( + { + "overall_dimensions_mm": analysis.get("overall_dimensions_mm"), + "door_mechanism": analysis.get("door_mechanism"), + "drawer_count": analysis.get("drawer_count"), + "estimation_consent": analysis.get("estimation_consent"), + "product_axis_contract": "X=左右宽,Y=前后深,Z=上下高;不得将顶板深度与产品高度直接比较", + "dimension_schedule": analysis.get("dimension_schedule") or {}, + "components": compact_components, + "assembled_instances": compact_instances, + "user_topology_constraints": analysis.get("user_topology_constraints") or {}, + "production_joinery": compact_joinery, + }, + ensure_ascii=False, + ) + ), + } + ] + contact_sheet = self._cad_review_contact_sheet(source_assets, built) + if contact_sheet: + content.append({ + "type": "text", + "text": ( + "这是带asset_id标签的六图复核主图,包含4张原图、CAD组装图和CAD爆炸图。" + "必须按每个标签逐格查看并输出对应view_findings。尺寸图另以高分辨率单独提供。" + ), + }) + content.append({"type": "image_url", "image_url": {"url": contact_sheet}}) + role_labels = self.ROLE_LABELS + individual_source_image_count = 0 + for item in source_assets: + if contact_sheet and str(item.get("role") or "") != "dimension": + continue + review_url = self._asset_review_data_url(item) + content.append( + { + "type": "text", + "text": ( + f"原始图片asset_id={item.get('asset_id') or 0},候选角色=" + f"{role_labels.get(str(item.get('role') or ''), '产品参考图')},文件名={item.get('filename') or ''}。" + "请独立判断实际视角、可见数量和尺寸,不得依赖候选角色或文件名。" + ), + } + ) + if review_url: + content.append({"type": "image_url", "image_url": {"url": review_url}}) + individual_source_image_count += 1 + individual_cad_image_count = 0 + for label, path in ( + ("爆炸拆件CAD预览", built.get("preview_path")), + ("组装CAD预览", built.get("assembled_preview_path")), + ): + if contact_sheet: + continue + data_url = self._preview_data_url(path) + if data_url: + content.append({"type": "text", "text": label}) + content.append({"type": "image_url", "image_url": {"url": data_url}}) + individual_cad_image_count += 1 + review_body = { + "model": self.model, + "messages": [{"role": "user", "content": content}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + # Six per-view findings plus the compact check contract can exceed + # 6k tokens on Chinese multimodal models even when issues are + # capped. A truncated JSON response is not a valid review, so leave + # enough headroom for the closing structure. + "max_tokens": 8192, + } + raw: dict = {} + review_attempts = 0 + review_failure = "" + required_check_keys = { + "assembly_reasonable", "drawer_complete", "door_hardware_consistent", "no_obvious_duplicate" + } + if source_assets: + required_check_keys.update({"source_images_consistent", "required_parts_complete"}) + if (analysis.get("production_joinery") or {}).get("applicable"): + required_check_keys.add("production_joinery_consistent") + for review_attempts in range(1, 3): + try: + raw = self._request_model_json( + review_body, + "CAD结构AI复核失败", + max_attempts=1, + timeout_seconds=self.cad_review_timeout, + ) + except SolidWoodCostError as exc: + review_failure = re.sub(r"\s+", " ", str(exc)).strip()[-500:] + break + returned_checks = raw.get("checks") if isinstance(raw.get("checks"), dict) else {} + valid_shape = ( + isinstance(raw.get("ok"), bool) + and isinstance(raw.get("issues"), list) + and isinstance(raw.get("checks"), dict) + and isinstance(raw.get("confidence"), (int, float)) + and required_check_keys.issubset(returned_checks) + and self._cad_review_findings_complete(raw, source_assets) + ) + if valid_shape: + break + review_body["messages"][0]["content"].append( + { + "type": "text", + "text": ( + "上一次CAD复核返回了空对象或缺少必填字段,不算复核完成。" + "请重新实际查看两张CAD图和BOM,必须返回ok、confidence、issues以及" + "checks必须完整包含source_images_consistent/required_parts_complete/assembly_reasonable/" + "drawer_complete/door_hardware_consistent/no_obvious_duplicate/production_joinery_consistent;" + "同时view_findings必须逐张覆盖每个原始asset_id。" + ), + } + ) + returned_checks = raw.get("checks") if isinstance(raw.get("checks"), dict) else {} + response_complete = ( + isinstance(raw.get("ok"), bool) + and isinstance(raw.get("issues"), list) + and isinstance(raw.get("checks"), dict) + and isinstance(raw.get("confidence"), (int, float)) + and required_check_keys.issubset(returned_checks) + and self._cad_review_findings_complete(raw, source_assets) + ) + # General CAD review models tend to copy a conservative default for + # module seams. When a tall casework product uses one continuous side + # per side and the first pass is not confident, run a narrow second + # look at the dimension drawing only. This avoids allowing a visually + # similar but materially different cut list and package size through. + segmentation_audits: list[dict] = [] + overall = analysis.get("overall_dimensions_mm") or {} + tall_casework = float(overall.get("z") or 0.0) >= max( + 900.0, + float(overall.get("x") or 0.0) * 1.5, + ) + side_instances = sum( + max(1, int(item.get("quantity") or 1)) + for item in (analysis.get("components") or []) + if any(token in self._component_key(item) for token in ("侧板", "side panel")) + and "抽屉" not in self._component_key(item) + ) + if response_complete and tall_casework and side_instances <= 2: + findings = raw.get("view_findings") or [] + for asset in source_assets: + if str(asset.get("role") or "") != "dimension": + continue + asset_id = int(self._float(asset.get("asset_id"), 0) or 0) + finding = next( + ( + item for item in findings + if isinstance(item, dict) + and int(self._float(item.get("asset_id"), 0) or 0) == asset_id + ), + None, + ) + if not finding: + continue + first_confidence = float( + self._float(finding.get("side_panel_segmentation_confidence"), 0.0) or 0.0 + ) + first_segments = max( + 0, + int(self._float(finding.get("side_panel_segments_per_side"), 0) or 0), + ) + first_heights = [ + float(value) + for value in (finding.get("side_panel_segment_heights_mm") or [])[:8] + if (self._float(value, 0.0) or 0.0) > 0 + ] + first_result_is_consistent = bool( + first_segments > 1 + and (not first_heights or len(first_heights) == first_segments) + ) + if first_confidence >= 0.80 and first_result_is_consistent: + continue + review_url = self._asset_review_data_url(asset) + if not review_url: + continue + audit_body = { + "model": self.model, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "只做家具侧板模块分段审计,不做其他识别。逐像素查看尺寸图中的横向拼接缝、" + "上下模块边界,以及沿总高方向连续标注的分段高度。连续的外轮廓线并不代表" + "一整块侧板;若横向接缝贯穿左右侧板或尺寸链把高度拆成多段,应按每侧实际" + "模块数报告。层板线若只在两侧板之间终止,不算侧板拼接缝。" + "side_panel_segment_heights_mm必须严格按产品从下到上排列,并把厘米换算为毫米。只返回JSON:" + '{"has_structural_side_segments":false,"side_panel_segments_per_side":0,' + '"side_panel_segment_heights_mm":[],"order":"bottom_to_top","confidence":0,' + '"evidence":""}。零值和空数组仅为格式,所有答案只从图中取得。' + "看不清时has_structural_side_segments=false且confidence必须低于0.6,禁止猜测。" + ), + }, + {"type": "image_url", "image_url": {"url": review_url}}, + ], + } + ], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": 1024, + } + try: + audit = self._request_model_json(audit_body, "侧板模块分段复核失败") + except SolidWoodCostError: + audit = {} + if not isinstance(audit, dict): + continue + segmentation_audits.append({"asset_id": asset_id, **audit}) + audit_confidence = min( + 1.0, + max(0.0, self._float(audit.get("confidence"), 0.0) or 0.0), + ) + audit_segments = max( + 0, + int(self._float(audit.get("side_panel_segments_per_side"), 0) or 0), + ) + if bool(audit.get("has_structural_side_segments")) and audit_segments > 1: + audit_heights = list(audit.get("side_panel_segment_heights_mm") or [])[:8] + if str(audit.get("order") or "").strip().lower() in { + "top_to_bottom", "top-to-bottom", "top down", "从上到下", + }: + audit_heights.reverse() + finding["side_panel_segments_per_side"] = audit_segments + finding["side_panel_segment_heights_mm"] = audit_heights + finding["side_panel_segmentation_confidence"] = audit_confidence + finding["evidence"] = str(audit.get("evidence") or finding.get("evidence") or "")[:200] + view_issues = self._cad_review_view_issues(analysis, raw, source_assets) + issues = list(deterministic) + view_issues + ignored_ai_issues: list[dict] = [] + eligible_ai_issue_count = 0 + for item in (raw.get("issues") or [])[:20]: + if not isinstance(item, dict): + continue + confidence = min(1.0, max(0.0, self._float(item.get("confidence"), 0.6) or 0.6)) + normalized = { + "code": str(item.get("code") or "ai_review_issue")[:80], + "severity": str(item.get("severity") or "warning")[:20], + "component": str(item.get("component") or "结构")[:100], + "description": str(item.get("description") or "CAD结构需复核")[:240], + "evidence": str(item.get("evidence") or "AI对CAD预览和BOM的联合检查")[:240], + "suggested_question": str(item.get("suggested_question") or "请确认该结构")[:200], + "requires_user_confirmation": bool(item.get("requires_user_confirmation")), + "confidence": confidence, + "source": "multimodal_ai", + } + normalized_text = " ".join( + str(normalized.get(key) or "") + for key in ("code", "component", "description", "evidence") + ).lower() + recorded_hardware = " ".join( + str(hardware.get("name") or "").lower() + for hardware in (analysis.get("recognized_hardware") or []) + ) + if ( + any(token in normalized_text for token in ("铰链", "hinge", "拉手", "handle", "knob")) + and any(token in normalized_text for token in ("未显示", "未建模", "缺少", "missing", "not shown")) + and ( + (any(token in normalized_text for token in ("铰链", "hinge")) and "铰链" in recorded_hardware) + or (any(token in normalized_text for token in ("拉手", "handle", "knob")) and "拉手" in recorded_hardware) + ) + ): + normalized.update( + { + "code": "hardware_cad_template_pending", + "severity": "warning", + "description": "五金已进入BOM与成本,但尚未绑定供应商CAD及排钻模板", + "suggested_question": "投产前由工程师绑定具体五金型号和孔位模板", + "requires_user_confirmation": False, + } + ) + if normalized["severity"] in {"blocking", "warning"} and confidence >= 0.55: + eligible_ai_issue_count += 1 + if self._ai_cad_issue_is_contradicted(analysis, normalized): + normalized["ignored_reason"] = "已被高置信尺寸表与CAD/BOM明细反证" + ignored_ai_issues.append(normalized) + else: + issues.append(normalized) + effective_model_ok = bool(raw.get("ok", True)) + if ( + not effective_model_ok + and eligible_ai_issue_count > 0 + and len(ignored_ai_issues) == eligible_ai_issue_count + and not deterministic + ): + effective_model_ok = True + review_confidence = min(1.0, max(0.0, self._float(raw.get("confidence"), 0.0) or 0.0)) + if not response_complete: + issues.append( + { + "code": "cad_review_response_incomplete", + "severity": "warning", + "component": "整体CAD", + "description": "AI未返回完整的CAD复核结构,已保留检查点且未进入报价", + "evidence": review_failure or "缺少ok/confidence/issues/checks中的必填字段", + "suggested_question": "请确认当前CAD结构,或补传更完整尺寸图后重试", + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "review_contract_gate", + } + ) + if not effective_model_ok and not issues: + issues.append( + { + "code": "cad_review_unresolved", + "severity": "warning", + "component": "整体CAD", + "description": "AI判定当前CAD未达到自动通过条件,但未返回可自动修复的明确条目", + "evidence": "AI复核ok=false", + "suggested_question": "请确认当前CAD结构,或补传更完整尺寸图", + "requires_user_confirmation": True, + "confidence": max(0.60, review_confidence), + "source": "review_gate", + } + ) + if review_confidence < 0.60: + issues.append( + { + "code": "cad_review_low_confidence", + "severity": "warning", + "component": "整体CAD", + "description": "AI对当前CAD结构的复核置信度不足", + "evidence": f"复核置信度{review_confidence:.0%}", + "suggested_question": "请确认当前CAD结构是否可用于预估", + "requires_user_confirmation": True, + "confidence": 1.0, + "source": "review_gate", + } + ) + raw_checks = dict(raw.get("checks") or {}) + effective_checks = dict(raw_checks) + false_required_checks = [ + key for key in sorted(required_check_keys) + if effective_checks.get(key) is False + ] + overridden_checks: list[str] = [] + if false_required_checks: + all_ai_findings_disproved = ( + eligible_ai_issue_count > 0 + and len(ignored_ai_issues) == eligible_ai_issue_count + and not deterministic + and not any( + item.get("severity") in {"blocking", "warning"} + and bool(item.get("requires_user_confirmation")) + for item in view_issues + ) + ) + if all_ai_findings_disproved: + # The reviewer sometimes leaves a summary check false after + # every concrete finding behind it has been disproved by the + # authoritative dimension ledger and generated CAD manifest. + # Keep the raw values for audit, but make the effective result + # internally consistent with the adjudicated issue list. + for key in false_required_checks: + effective_checks[key] = True + overridden_checks = false_required_checks + elif not any( + item.get("severity") in {"blocking", "warning"} + and bool(item.get("requires_user_confirmation")) + for item in issues + ): + effective_model_ok = False + issues.append( + { + "code": "cad_review_check_failed_without_issue", + "severity": "warning", + "component": "整体CAD", + "description": "AI复核检查项未通过,但没有返回可定位的具体问题", + "evidence": "未通过检查:" + "、".join(false_required_checks), + "suggested_question": "请重新复核CAD或补充更明确的尺寸图", + "requires_user_confirmation": True, + "confidence": max(0.60, review_confidence), + "source": "review_contract_gate", + } + ) + deduplicated_issues: list[dict] = [] + seen_issue_keys: set[tuple[str, str, str]] = set() + for item in issues: + issue_key = ( + str(item.get("code") or ""), + str(item.get("component") or ""), + str(item.get("description") or ""), + ) + if issue_key in seen_issue_keys: + continue + seen_issue_keys.add(issue_key) + deduplicated_issues.append(item) + issues = deduplicated_issues + requires_confirmation = any( + bool(item.get("requires_user_confirmation")) or item.get("severity") == "blocking" + for item in issues + ) + return { + "reviewed_at": self._now(), + "ok": not requires_confirmation and effective_model_ok, + "confidence": review_confidence, + "issues": issues[:25], + "checks": effective_checks, + "raw_checks": raw_checks if overridden_checks else {}, + "overridden_checks": overridden_checks, + "requires_confirmation": requires_confirmation, + "model": self.model, + "response_complete": response_complete, + "attempts": review_attempts, + "source_asset_count": len(source_assets), + "individual_source_images_sent": individual_source_image_count, + "individual_cad_images_sent": individual_cad_image_count, + "contact_sheet_sent": bool(contact_sheet), + "view_findings": list(raw.get("view_findings") or [])[:16], + "ignored_ai_issues": ignored_ai_issues, + "segmentation_audits": segmentation_audits, + } + + def _cad_review_answer(self, project: dict) -> str: + analysis = project.get("analysis") or {} + review = analysis.get("cad_review") or {} + review_issues = list(review.get("issues") or []) + issue_rows = [] + for item in review_issues: + issue_rows.append( + f"| {item.get('component') or '结构'} | {item.get('description') or '需复核'} | " + f"{float(item.get('confidence') or 0):.0%} | {item.get('suggested_question') or '请确认'} |" + ) + deterministic_codes = { + str(item.get("code") or "") + for item in review_issues + if str(item.get("source") or "").startswith("deterministic") + or str(item.get("severity") or "") == "blocking" + } + actions: list[dict] = [] + has_back_fit = "back_panel_groove_fit_mismatch" in deterministic_codes + has_leg_connection = "leg_connection_unspecified" in deterministic_codes + if has_back_fit or has_leg_connection: + decisions = [] + if has_back_fit: + decisions.append("背板确认采用四边6mm嵌槽,并按净开口加双边槽深修正背板生产尺寸") + if has_leg_connection: + decisions.append("柜腿确认采用M8吊脚螺栓加柜底预埋螺母连接") + actions.append( + { + "label": "采用推荐修正并重建", + "message": "【技能路由:ai-solid-wood-cost】" + ";".join(decisions) + "。请重新生成CAD并复核。", + "intent": "correct_cad", + } + ) + if not deterministic_codes: + actions.append( + { + "label": "确认当前CAD继续", + "message": "【技能路由:ai-solid-wood-cost】确认当前CAD结构完整,继续生成报价", + "intent": "approve_cad_review", + } + ) + actions.extend( + [ + {"label": "单张补传尺寸图", "message": "【技能路由:ai-solid-wood-cost】我要单张补传更完整的尺寸图", "intent": "supplement_dimension"}, + {"label": "填写修正尺寸", "message": "【技能路由:ai-solid-wood-cost】修正尺寸:", "intent": "correct_cad"}, + ] + ) + next_step = ( + "上表包含确定性结构阻断项,不能直接跳过。请采用推荐修正、填写实际尺寸,或单张补传尺寸图。" + if deterministic_codes + else "可确认这份CAD用于成本预估,也可单张补传尺寸图,或在对话中写出修正尺寸。" + ) + return ( + f"### {self._display_product_name(analysis)}:CAD结构复核\n\n" + "> **当前步骤:** CAD已生成,AI已把原始多视角图片、尺寸图、组装图、爆炸图和BOM放在一起复核。存在待确认项时不会直接出价。\n\n" + "| 部件/结构 | 复核发现 | 置信度 | 需要确认 |\n| --- | --- | ---: | --- |\n" + + ("\n".join(issue_rows) if issue_rows else "| 整体 | 未发现阻断性异常 | - | - |") + + f"\n\n{next_step}\n\n" + "```openclaw-actions\n" + + json.dumps( + { + "openclaw_actions": True, + "type": "quick_replies", + "title": "确认CAD结构", + "actions": actions, + }, + ensure_ascii=False, + indent=2, + ) + + "\n```" + ) + + def _build_and_quote(self, project: dict, progress: ProgressCallback | None) -> dict: + project_id = project["project_id"] + job_dir = self.state_dir / "jobs" / project_id + job_dir.mkdir(parents=True, exist_ok=True) + build_analysis = self._analysis_for_selected_thickness(project["analysis"], project.get("selections") or {}) + build_analysis = self._apply_production_joinery(build_analysis, project.get("selections") or {}) + build_project = {**project, "analysis": build_analysis} + source_path = job_dir / "furniture.step.py" + source_path.write_text(self._source_for_components(build_analysis, layout="exploded"), encoding="utf-8") + + def emit(stage: str, text: str, current: int, total: int = 10) -> None: + self._update_project(project_id, stage, text) + if progress: + progress(stage, text, current, total) + + emit("bom_validating", "正在复核各部件尺寸、数量、连接孔位、背板槽和抽屉底板槽", 4) + ai3d = self._load_ai3d() + ai3d._validate_source(source_path.read_text(encoding="utf-8")) + emit("cad_building", "正在把连接孔位和装配槽实际切入CAD,并生成整装和爆炸拆件两种3D模型", 5) + + def cad_progress(stage: str, text: str, current: int, total: int) -> None: + mapped = 5 if current <= 4 else 6 + emit(f"cad_{stage}", text, mapped) + + def packaging_progress(stage: str, text: str, current: int, total: int) -> None: + # 包装模型位于第8步,不能复用主体CAD的5/6步映射造成前端进度倒退。 + emit(f"packaging_{stage}", f"包装叠放模型:{text}", 8) + + built = ai3d._build_and_validate( + job_dir, + source_path.name, + requested_exports={"stl", "glb"}, + progress=cad_progress, + ) + assembled_dir = job_dir / "assembled" + assembled_dir.mkdir(parents=True, exist_ok=True) + assembled_source_path = assembled_dir / "furniture-assembled.step.py" + assembled_source_path.write_text( + self._source_for_components(build_analysis, layout="assembled"), + encoding="utf-8", + ) + ai3d._validate_source(assembled_source_path.read_text(encoding="utf-8")) + assembled_built = ai3d._build_and_validate( + assembled_dir, + assembled_source_path.name, + requested_exports={"glb"}, + progress=cad_progress, + ) + built.update( + { + "assembled_glb_path": assembled_built["glb_path"], + "assembled_preview_path": assembled_built["preview_path"], + } + ) + emit("cad_ai_review", "CAD已生成,AI正在对照原始多视角图片、尺寸图、组装图、爆炸图和BOM复核漏件错件", 6) + approved_review = build_analysis.get("cad_review_approved") or {} + deterministic_blockers = self._deterministic_cad_review_issues(build_analysis) + approval_is_safe = bool(approved_review.get("approved")) and not deterministic_blockers + if approval_is_safe and build_analysis.get("cad_review"): + cad_review = dict(build_analysis.get("cad_review") or {}) + else: + cad_review = self._review_cad_model( + build_analysis, + built, + self._assets(project_id), + ) + build_analysis["cad_review"] = cad_review + build_project = {**project, "analysis": build_analysis} + if cad_review.get("requires_confirmation") and not approval_is_safe: + # High-confidence disagreements that are fully evidenced by the + # uploaded views should be repaired by the system, not converted + # into another user confirmation step. Keep a strict retry cap; + # ambiguous material/structure choices still fall through to the + # confirmation UI below. + auto_repair_attempts = max( + 0, + int(self._float(build_analysis.get("cad_auto_repair_attempts"), 0) or 0), + ) + issue_codes = { + str(item.get("code") or "") + for item in (cad_review.get("issues") or []) + if isinstance(item, dict) + } + repairable_evidence = bool( + "source_side_panel_segmentation_mismatch" in issue_codes + or any(code.startswith("source_") and code.endswith("_count_mismatch") for code in issue_codes) + ) + if auto_repair_attempts < 2 and repairable_evidence: + repaired_analysis, repair_patch = self._apply_high_confidence_cad_review_patch(build_analysis) + repaired_confidence = dict((repair_patch or {}).get("confidence") or {}) + addressed = bool( + ( + "source_side_panel_segmentation_mismatch" in issue_codes + and float(repaired_confidence.get("side_segments_per_side") or 0.0) >= 0.80 + ) + or any( + code == f"source_{key}_count_mismatch" + and float(repaired_confidence.get(key) or 0.0) >= 0.80 + for key in ("doors", "drawers", "shelves") + for code in issue_codes + ) + ) + if repair_patch and addressed: + repaired_analysis["cad_auto_repair_attempts"] = auto_repair_attempts + 1 + repaired_analysis["cad_auto_repair_history"] = list( + build_analysis.get("cad_auto_repair_history") or [] + ) + [ + { + "attempt": auto_repair_attempts + 1, + "issue_codes": sorted(issue_codes), + "patch": repair_patch, + } + ] + repaired_analysis.pop("cad_review", None) + repaired_analysis.pop("cad_review_approved", None) + if progress: + progress( + "cad_auto_repair", + "AI已从多视角证据定位结构差异,正在自动修正并重新生成CAD", + 6, + 10, + ) + self._save_bom_draft(project_id, repaired_analysis) + repaired_project = self._update_project( + project_id, + "confirmed", + "AI多视角结构修正已应用,正在自动重建CAD", + analysis=repaired_analysis, + ) + return self._build_and_quote(repaired_project, progress) + self._save_bom_draft(project_id, build_analysis) + waiting_project = self._update_project( + project_id, + "waiting_cad_review", + "CAD和BOM已生成,AI发现结构待确认项,未进入报价", + analysis=build_analysis, + result={"draft_cad": built, "cad_review": cad_review}, + ) + return { + "status": "waiting", + "answer": self._cad_review_answer(waiting_project), + "files": [], + "project_id": project_id, + } + emit("costing", "CAD校验通过,正在按独立价格库复核材料、五金、人工和包装成本", 7) + quote = self._calculate_quote(build_project) + quote["cad_review"] = cad_review + emit("packaging_cad", "正在生成外箱、缓冲层、隔板、配件盒和产品逐层对应的包装CAD炸开模型", 8) + packaging_dir = job_dir / "packaging" + packaging_dir.mkdir(parents=True, exist_ok=True) + packaging_source_path = packaging_dir / "furniture-packaging.step.py" + packaging_source_path.write_text( + self._source_for_packaging(quote["packaging_bom"]), encoding="utf-8" + ) + ai3d._validate_source(packaging_source_path.read_text(encoding="utf-8")) + packaging_built = ai3d._build_and_validate( + packaging_dir, + packaging_source_path.name, + requested_exports={"glb"}, + progress=packaging_progress, + ) + built.update( + { + "packaging_glb_path": packaging_built["glb_path"], + "packaging_preview_path": packaging_built["preview_path"], + "interaction_manifest_path": self._write_interaction_manifest( + job_dir, project_id, build_analysis, quote["packaging_bom"] + ), + } + ) + emit("production_drawing", "正在校验材料与连接工艺,生成带基准、孔位、槽位和发布状态的生产图包", 9) + drawing_files = self._write_manufacturing_drawings( + job_dir, project_id, build_analysis, quote + ) + quote["production_drawing"] = drawing_files["production_drawing_manifest"] + files = self._write_quote_files(job_dir, project_id, quote) + result = { + "quote": quote, + "cad_review": cad_review, + "files": [], + **built, + **drawing_files, + **files, + } + quote_id = f"quote_{uuid.uuid4().hex[:16]}" + with self._connect() as conn: + conn.execute( + "INSERT INTO quote_versions(quote_id,project_id,price_version_id,quote_json,created_at) VALUES(?,?,?,?,?)", + (quote_id, project_id, quote["price_version"]["version_id"], json.dumps(quote, ensure_ascii=False), self._now()), + ) + for line in quote["material_lines"]: + conn.execute( + """ + UPDATE bom_components + SET dimensions_json=?,selected_species=?,selected_material_family=?,selected_material_code=?, + selected_profile_json=?,selected_wall_thickness_mm=?,net_weight_kg=? + WHERE project_id=? AND part_id=? + """, + ( + json.dumps(line.get("dimensions_mm") or {}, ensure_ascii=False), + line["species"], + str(line.get("material_family") or ""), + str(line.get("metal_material_code") or line.get("panel_code") or ""), + json.dumps( + { + "profile": line.get("metal_profile"), + "kg_m": line.get("theoretical_kg_m"), + "length_m": line.get("metal_length_m"), + }, + ensure_ascii=False, + ), + float(line.get("wall_thickness_mm") or 0), + float(line.get("net_weight_kg") or 0), + project_id, + line["part_id"], + ), + ) + emit("delivering", "BOM成本、生产图包和交付文件已生成,正在保存并进入1688报价设置", 10) + selections = dict(project.get("selections") or {}) + for key in ( + "wholesale_1688_enabled", "special_operation_codes", "special_operation_material_basis", + "invoice_tax_enabled", "profit_margin", + ): + selections.pop(key, None) + selections["post_quote_step"] = "wholesale_need" + waiting_project = self._update_project( + project_id, + "waiting_confirmation", + "BOM成本已完成,等待确认是否需要1688批发价", + # Persist the exact analysis used to build CAD and price the BOM. + # Without this, post-quote inspection and follow-up edits saw the + # pre-thickness/pre-joinery draft even though delivered CAD was + # generated from the corrected build analysis. + analysis=build_analysis, + selections=selections, + result=result, + ) + return { + "status": "waiting", + "answer": self._post_quote_answer(waiting_project), + "files": [], + "project_id": project_id, + } + + def run( + self, + message: str, + *, + conversation_id: str, + user_id: str, + session_key: str, + progress: ProgressCallback | None = None, + image_inputs: list[dict] | None = None, + ) -> dict: + message = re.sub(r"【技能路由[::]ai-solid-wood-cost】", "", str(message or "")).strip() + context_key = self.context_key(conversation_id, user_id, session_key) + project = self.latest_project(context_key) + inputs = [item for item in (image_inputs or []) if isinstance(item, dict)][:8] + if self.ENTRY_RE.fullmatch(message) and not inputs: + if not project or project.get("stage") in self.TERMINAL_STAGES: + project = self._new_project(context_key) + return {"status": "waiting", "answer": self._assets_answer(project), "files": []} + if self.PROGRESS_RE.fullmatch(message): + if not project: + project = self._new_project(context_key) + if project["stage"] == "completed": + return {"status": "completed", "answer": self._completed_answer(project), "files": (project.get("result") or {}).get("files") or []} + if project["stage"] == "failed": + project = self._update_project( + project["project_id"], + "waiting_assets", + "已保留原图片,正在重新执行家具多视图识别", + error="", + ) + else: + if project["stage"] == "waiting_structure": + return {"status": "waiting", "answer": self._structure_answer(project), "files": []} + if project["stage"].startswith("waiting_"): + answer = self._answer_for_stage(project["stage"], project) + return {"status": "waiting", "answer": answer, "files": []} + return {"status": "waiting", "answer": f"### {self.DISPLAY_NAME}进度\n\n{project['display_text']}\n\n项目号:`{project['project_id']}`", "files": []} + if not project or (project.get("stage") == "completed" and inputs): + project = self._new_project(context_key) + elif project.get("stage") == "failed" and inputs: + project = self._update_project( + project["project_id"], + "waiting_assets", + "已保留原图片,正在重新执行家具多视图识别", + error="", + ) + completed_post_quote_change = bool( + project.get("stage") == "completed" + and (project.get("result") or {}).get("quote") + and ( + re.search(r"(?:1688.{0,10}批发|批发.{0,10}1688)", message, re.I) + or "特殊操作" in message + or "只要BOM出厂报价" in message.upper() + ) + ) + if completed_post_quote_change: + # A user may change the 1688 decision after first choosing "BOM only". + # Reopen the same quote instead of letting the global 1688 sourcing + # route start an unrelated purchase-search task. + selections = dict(project.get("selections") or {}) + for key in ( + "wholesale_1688_enabled", + "special_operation_codes", + "special_operation_material_basis", + "invoice_tax_enabled", + "profit_margin", + ): + selections.pop(key, None) + selections["post_quote_step"] = "wholesale_need" + project = self._update_project( + project["project_id"], + "waiting_confirmation", + "已重新打开1688批发报价设置", + selections=selections, + ) + return self._handle_post_quote( + project, + message, + conversation_id=conversation_id, + user_id=user_id, + ) + menu_match = re.search(r"打开修改菜单\s*[::]\s*(basic|panel|surface|metal|production)", message, re.I) + if project.get("stage") in {"completed", "waiting_confirmation"} and menu_match: + return { + "status": "waiting", + "answer": self._confirmation_edit_menu_answer(project, menu_match.group(1).lower()), + "files": [], + "project_id": project["project_id"], + } + if project.get("stage") in {"completed", "waiting_confirmation"} and "返回报价确认" in message: + answer = ( + self._post_quote_answer(project) + if (project.get("result") or {}).get("quote") and (project.get("selections") or {}).get("post_quote_step") + else self._confirmation_answer(project) + ) + return {"status": "waiting", "answer": answer, "files": [], "project_id": project["project_id"]} + edit_match = re.search(r"修改步骤\s*[::]\s*([^\s,。]+)", message) + if project.get("stage") in {"completed", "waiting_confirmation"} and edit_match: + edited = self._begin_confirmation_edit(project, edit_match.group(1)) + if edited: + return edited + if ( + project.get("stage") == "waiting_confirmation" + and (project.get("result") or {}).get("quote") + and (project.get("selections") or {}).get("post_quote_step") + and not any(word in message for word in ("修改", "更换")) + ): + try: + return self._handle_post_quote( + project, + message, + conversation_id=conversation_id, + user_id=user_id, + ) + except Exception as exc: + friendly = re.sub(r"\s+", " ", str(exc)).strip()[-1200:] or "1688批发报价或归档失败" + self._update_project(project["project_id"], "failed", "1688批发报价和归档未完成", error=friendly) + return {"status": "failed", "answer": f"### {self.DISPLAY_NAME}未完成\n\n任务没有被标记为成功。\n\n失败原因:{friendly}", "files": []} + if project.get("stage") in {"completed", "waiting_confirmation"} and "更换" in message and "材质" in message: + edited = self._begin_confirmation_edit(project, "用材结构") + if edited: + return edited + if project.get("stage") in {"completed", "waiting_confirmation"} and "修改" in message and "金属" in message: + edited = self._begin_confirmation_edit(project, "金属部件范围") + if edited: + return edited + if project.get("stage") in {"completed", "waiting_confirmation"} and "修改" in message and any(word in message for word in ("油漆", "工艺")): + edited = self._begin_confirmation_edit(project, "饰面油漆") + if edited: + return edited + if project.get("stage") in {"completed", "waiting_confirmation"} and "修改" in message and any(word in message for word in ("生产", "批量", "包装", "运输")): + edited = self._begin_confirmation_edit(project, "包装方式") + if edited: + return edited + if project.get("stage") == "completed" and any(word in message.lower() for word in ("完整部件bom", "完整bom", "查看bom", "bom明细")): + return {"status": "completed", "answer": self._completed_answer(project), "files": (project.get("result") or {}).get("files") or []} + if project["stage"] == "waiting_cad_review": + if inputs or ("补传" in message and "尺寸" in message): + self._request_dimension_supplement(project["project_id"]) + selections = dict(project.get("selections") or {}) + selections["_resume_after_asset_validation"] = "cad_rebuild" + project = self._update_project( + project["project_id"], + "waiting_assets", + "CAD复核检查点已保留,等待单张补传详细尺寸图", + selections=selections, + ) + if inputs: + return self.run( + message, + conversation_id=conversation_id, + user_id=user_id, + session_key=session_key, + progress=progress, + image_inputs=inputs, + ) + return {"status": "waiting", "answer": self._assets_answer(project), "files": []} + if any(token in message for token in ("确认当前CAD", "确认CAD结构", "按当前CAD继续", "继续生成报价")): + analysis = json.loads(json.dumps(project.get("analysis") or {}, ensure_ascii=False)) + deterministic_blockers = self._deterministic_cad_review_issues(analysis) + if deterministic_blockers: + existing_review = dict(analysis.get("cad_review") or {}) + non_deterministic = [ + item for item in (existing_review.get("issues") or []) + if not str(item.get("source") or "").startswith("deterministic") + ] + existing_review.update( + { + "ok": False, + "issues": deterministic_blockers + non_deterministic, + "requires_confirmation": True, + } + ) + analysis["cad_review"] = existing_review + blocked_project = self._update_project( + project["project_id"], + "waiting_cad_review", + "CAD确定性数量校验未通过,不能跳过后直接报价", + analysis=analysis, + ) + return {"status": "waiting", "answer": self._cad_review_answer(blocked_project), "files": []} + analysis["cad_review_approved"] = { + "approved": True, + "source": "user_confirmed_after_ai_review", + "approved_at": self._now(), + } + project = self._update_project( + project["project_id"], + "confirmed", + "用户已确认CAD复核项,正在生成成本报价", + analysis=analysis, + ) + try: + return self._build_and_quote(project, progress) + except Exception as exc: + friendly = re.sub(r"\s+", " ", str(exc)).strip()[-1200:] or "CAD或报价生成失败" + self._update_project(project["project_id"], "failed", "CAD或报价生成未完成", error=friendly) + return {"status": "failed", "answer": f"### {self.DISPLAY_NAME}未完成\n\n任务没有被错误标记为成功。\n\n失败原因:{friendly}", "files": []} + if "填写修正尺寸" in message and not re.search(r"\d\s*[x×*]\s*\d", message): + return {"status": "waiting", "answer": self._cad_review_answer(project), "files": []} + analysis = json.loads(json.dumps(project.get("analysis") or {}, ensure_ascii=False)) + analysis.pop("cad_review_approved", None) + topology_before = json.dumps( + { + "components": analysis.get("components") or [], + "constraints": analysis.get("user_topology_constraints") or {}, + }, + ensure_ascii=False, + sort_keys=True, + ) + ai_self_repair_request = self._message_requests_ai_cad_self_repair(message) + topology_changed = False + if not ai_self_repair_request: + topology_candidate = self._apply_user_structure_overrides(analysis, message) + topology_after = json.dumps( + { + "components": topology_candidate.get("components") or [], + "constraints": topology_candidate.get("user_topology_constraints") or {}, + }, + ensure_ascii=False, + sort_keys=True, + ) + topology_changed = topology_after != topology_before + if topology_changed: + analysis = topology_candidate + directly_changed = self._apply_user_component_dimensions(analysis, message) + connection_changed = self._apply_user_connection_decisions(analysis, message) + cad_review_patch: dict = {} + if not (topology_changed or directly_changed or connection_changed): + analysis, cad_review_patch = self._apply_high_confidence_cad_review_patch(analysis) + if topology_changed or directly_changed or connection_changed or cad_review_patch: + analysis.pop("production_joinery", None) + analysis["structure_questions"] = self._critical_structure_questions(analysis) + else: + previous_analysis = json.loads(json.dumps(analysis, ensure_ascii=False)) + accepted_assets = [ + item for item in self._assets(project["project_id"]) + if item.get("validation_status") == "accepted" + ] + if progress: + progress("cad_correction", "正在结合用户修正重新识别部件和尺寸", 3, 9) + confirmed_context = { + "overall_dimensions_mm": analysis.get("overall_dimensions_mm"), + "dimension_evidence": analysis.get("dimension_evidence"), + "door_mechanism": analysis.get("door_mechanism"), + "door_dimensions_mm": analysis.get("door_dimensions_mm"), + "structure_decisions": analysis.get("structure_decisions"), + "estimation_consent": analysis.get("estimation_consent"), + "user_topology_constraints": analysis.get("user_topology_constraints"), + "cad_review_issues": (analysis.get("cad_review") or {}).get("issues"), + } + analysis = self._vision_analyze( + accepted_assets, + f"用户针对CAD复核提供修正:{message}\n已确认上下文:" + + json.dumps(confirmed_context, ensure_ascii=False), + ) + if confirmed_context.get("estimation_consent"): + analysis["estimation_consent"] = confirmed_context["estimation_consent"] + analysis["structure_questions"] = self._critical_structure_questions(analysis) + analysis = self._restore_confirmed_topology_after_reanalysis(previous_analysis, analysis) + analysis["structure_questions"] = self._critical_structure_questions(analysis) + analysis.pop("cad_review", None) + self._save_bom_draft(project["project_id"], analysis) + if analysis.get("structure_questions"): + analysis["resume_after_structure"] = "cad_rebuild" + project = self._update_project( + project["project_id"], "waiting_structure", "CAD修正已保存,等待确认关键结构", analysis=analysis + ) + return {"status": "waiting", "answer": self._structure_answer(project), "files": []} + project = self._update_project( + project["project_id"], "confirmed", "CAD修正已保存,正在重新生成和AI复核", analysis=analysis + ) + try: + return self._build_and_quote(project, progress) + except Exception as exc: + friendly = re.sub(r"\s+", " ", str(exc)).strip()[-1200:] or "CAD修正后重新生成失败" + self._update_project(project["project_id"], "failed", "CAD修正后重新生成未完成", error=friendly) + return {"status": "failed", "answer": f"### {self.DISPLAY_NAME}未完成\n\n失败原因:{friendly}", "files": []} + if project["stage"] == "waiting_assets": + if inputs: + self._store_assets(project["project_id"], inputs) + assets = self._assets(project["project_id"]) + if len({x["role"] for x in assets}) < 4: + return {"status": "waiting", "answer": self._assets_answer(project), "files": []} + if not inputs and assets and all(item.get("validation_status") != "pending" for item in assets): + accepted_roles = {item["role"] for item in assets if item.get("validation_status") == "accepted"} + if len(accepted_roles) < 4: + return {"status": "waiting", "answer": self._assets_answer(project), "files": []} + try: + if progress: + progress("asset_check", "正在逐张检查视角、清晰度、同款一致性和尺寸标注", 1, 9) + validation = self._validate_assets_multimodal(project["project_id"], assets, message) + if not validation.get("ok"): + project = self._update_project( + project["project_id"], "waiting_assets", "图片检查完成,等待单张补传不合格视角" + ) + return {"status": "waiting", "answer": self._assets_answer(project), "files": []} + assets = [item for item in self._assets(project["project_id"]) if item.get("validation_status") == "accepted"] + self._update_project(project["project_id"], "vision", "正在识别产品结构、尺寸和部件边界") + if progress: + progress("vision", "四类图片已通过,正在融合多视角和尺寸图", 2, 9) + analysis = self._vision_analyze(assets, message) + self._save_bom_draft(project["project_id"], analysis) + selections = dict(project.get("selections") or {}) + resume_after_assets = selections.pop("_resume_after_asset_validation", "") + if resume_after_assets == "cad_rebuild": + if analysis.get("structure_questions"): + analysis["resume_after_structure"] = "cad_rebuild" + project = self._update_project( + project["project_id"], + "waiting_structure", + "新尺寸图已识别,等待确认剩余低置信结构", + analysis=analysis, + selections=selections, + ) + return {"status": "waiting", "answer": self._structure_answer(project), "files": []} + project = self._update_project( + project["project_id"], + "confirmed", + "新尺寸图已通过,正在重建CAD并复核", + analysis=analysis, + selections=selections, + ) + return self._build_and_quote(project, progress) + next_stage = "waiting_structure" if analysis.get("structure_questions") else "waiting_cost_purpose" + next_text = "BOM草案已拆分,等待确认缺失的关键结构" if next_stage == "waiting_structure" else "部件拆分完成,等待选择成本用途" + project = self._update_project(project["project_id"], next_stage, next_text, analysis=analysis) + if progress: + progress("bom_draft", f"已拆分{len(analysis['components'])}类部件,正在确认评估条件", 3, 9) + answer = self._structure_answer(project) if next_stage == "waiting_structure" else self._cost_purpose_answer(project) + return {"status": "waiting", "answer": answer, "files": []} + except Exception as exc: + friendly = re.sub(r"\s+", " ", str(exc)).strip()[-1200:] or "家具读图失败" + self._update_project(project["project_id"], "failed", "家具多视图识别未完成", error=friendly) + return {"status": "failed", "answer": f"### {self.DISPLAY_NAME}未完成\n\n任务没有被标记为成功。\n\n失败原因:{friendly}", "files": []} + if project["stage"] == "waiting_structure": + if inputs or ("补传" in message and "尺寸" in message): + self._request_dimension_supplement(project["project_id"]) + project = self._update_project( + project["project_id"], + "waiting_assets", + "其他3张合格图已保留,等待单张补传详细尺寸图", + analysis=project.get("analysis") or {}, + ) + if inputs: + return self.run( + message, + conversation_id=conversation_id, + user_id=user_id, + session_key=session_key, + progress=progress, + image_inputs=inputs, + ) + return {"status": "waiting", "answer": self._assets_answer(project), "files": []} + analysis, resolved = self._resolve_structure(project.get("analysis") or {}, message) + if not resolved: + project = self._update_project( + project["project_id"], "waiting_structure", "等待确认缺失的关键结构", analysis=analysis + ) + return {"status": "waiting", "answer": self._structure_answer(project), "files": []} + self._save_bom_draft(project["project_id"], analysis) + if analysis.pop("resume_after_structure", "") == "cad_rebuild": + project = self._update_project( + project["project_id"], "confirmed", "关键结构已确认,正在重建CAD并执行AI复核", + analysis=analysis, + ) + try: + return self._build_and_quote(project, progress) + except Exception as exc: + friendly = re.sub(r"\s+", " ", str(exc)).strip()[-1200:] or "CAD重建失败" + self._update_project(project["project_id"], "failed", "CAD重建未完成", error=friendly) + return {"status": "failed", "answer": f"### {self.DISPLAY_NAME}未完成\n\n失败原因:{friendly}", "files": []} + project = self._update_project( + project["project_id"], "waiting_cost_purpose", "关键结构已确认,等待选择成本用途", + analysis=analysis, + ) + return {"status": "waiting", "answer": self._cost_purpose_answer(project), "files": []} + configuration_stages = { + "waiting_cost_purpose", "waiting_material_structure", "waiting_material", "waiting_panel_specs", + "waiting_panel_thickness", "waiting_emission_grade", "waiting_panel_purchase", "waiting_finish", + "waiting_edge_material", "waiting_edge_thickness", "waiting_edge_scope", "waiting_hardware", "waiting_finish_details", + "waiting_metal_scope", "waiting_metal_material", "waiting_metal_profile", "waiting_metal_wall", + "waiting_metal_joining", "waiting_metal_finish", + "waiting_process", "waiting_batch", "waiting_packaging_mode", "waiting_packaging_box_style", "waiting_carton_grade", "waiting_production", "waiting_confirmation", + } + if project["stage"] in configuration_stages: + selections = self._parse_selection(message, project) + # User-facing estimates always use one stable mass-production basis. + # Direct engine calls can still supply other quantities for internal sensitivity tests. + selections["batch_qty"] = self.DEFAULT_BATCH_QTY + if project["stage"] != "waiting_cost_purpose" and not selections.get("cost_purpose"): + # Existing checkpoints created before v3 did not have this field. + selections["cost_purpose"] = "概算" + if project["stage"] == "waiting_material": + # Material selection must remain a visible standalone decision. Ignore + # finish words embedded in legacy material buttons/messages. + for key in ("finish", "finish_color", "finish_gloss", "finish_texture"): + selections.pop(key, None) + if selections.get("shipment_mode") in {"整装", "拆装"} and not selections.get("shipping_scenario"): + selections["shipping_scenario"] = "电商快递" + next_stage = self._next_configuration_stage(project, selections) + if next_stage != "confirmed": + display_names = { + "waiting_cost_purpose": "等待选择成本用途", + "waiting_material_structure": "等待选择用材结构", + "waiting_material": "等待选择具体材料", + "waiting_panel_thickness": "等待确认板材厚度", + "waiting_emission_grade": "等待确认环保等级", + "waiting_panel_purchase": "等待确认板材采购状态", + "waiting_finish": "等待选择饰面或油漆", + "waiting_edge_material": "等待选择封边材质", + "waiting_edge_thickness": "等待选择封边厚度", + "waiting_edge_scope": "等待选择封边范围", + "waiting_hardware": "等待选择五金档次", + "waiting_finish_details": "等待确认油漆颜色、纹理和光泽", + "waiting_metal_scope": "等待确认金属部件范围", + "waiting_metal_material": "等待选择金属材质", + "waiting_metal_profile": "等待选择金属型材截面", + "waiting_metal_wall": "等待确认金属壁厚", + "waiting_metal_joining": "等待确认金属连接工艺", + "waiting_metal_finish": "等待选择金属表面处理", + "waiting_process": "等待确认异形和雕刻工艺", + "waiting_batch": "等待确认生产数量", + "waiting_packaging_mode": "等待选择拆装或整装包装", + "waiting_packaging_box_style": "等待选择包装箱型", + "waiting_carton_grade": "等待选择纸箱成本等级", + "waiting_confirmation": "等待最终确认生成报价", + } + project = self._update_project( + project["project_id"], next_stage, display_names.get(next_stage, "等待补充评估条件"), selections=selections + ) + return {"status": "waiting", "answer": self._answer_for_stage(next_stage, project), "files": []} + project = self._update_project(project["project_id"], "confirmed", "全部条件已确认,准备生成CAD、包装模拟和成本台账", selections=selections) + try: + return self._build_and_quote(project, progress) + except Exception as exc: + friendly = re.sub(r"\s+", " ", str(exc)).strip()[-1200:] or "CAD或报价生成失败" + self._update_project(project["project_id"], "failed", "CAD或报价生成未完成", error=friendly) + return {"status": "failed", "answer": f"### {self.DISPLAY_NAME}未完成\n\n任务没有被错误标记为成功。\n\n失败原因:{friendly}", "files": []} + if project["stage"] == "completed": + return {"status": "completed", "answer": self._completed_answer(project), "files": (project.get("result") or {}).get("files") or []} + return {"status": "waiting", "answer": f"### {self.DISPLAY_NAME}进度\n\n{project['display_text']}", "files": []} + + def attach_files(self, project_id: str, files: list[dict]) -> dict | None: + project = self.get_project(project_id) + if not project: + return None + result = dict(project.get("result") or {}) + result["files"] = [dict(item) for item in files if isinstance(item, dict)] + if (project.get("selections") or {}).get("post_quote_step") not in {None, "", "completed"}: + self._update_project( + project_id, + "waiting_confirmation", + "BOM成本及交付文件已保存,等待完成1688报价设置", + result=result, + ) + # Chat API上传完成时不得把尚未结束的后续交互误标为completed。 + return None + return self._update_project(project_id, "completed", "CAD、BOM、成本报价和交付文件已全部完成", result=result) + + def mark_delivery_failed(self, project_id: str, error: str) -> dict | None: + project = self.get_project(project_id) + if not project: + return None + return self._update_project( + project_id, + "failed", + "CAD和报价已生成,但交付文件上传失败", + result=dict(project.get("result") or {}), + error=str(error or "文件上传失败")[:1200], + ) diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py index 1f655ea..b88dd35 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py @@ -79,6 +79,9 @@ class ManufacturingOracleTest(unittest.TestCase): feedback = terminal_dependency_feedback(RuntimeError("家具读图模型尚未配置密钥")) self.assertEqual("model_credentials_missing", feedback["code"]) self.assertFalse(feedback["retryable"]) + invalid = terminal_dependency_feedback(RuntimeError("读图模型鉴权失败(HTTP 401)")) + self.assertEqual("model_credentials_invalid", invalid["code"]) + self.assertFalse(invalid["retryable"]) self.assertIsNone(terminal_dependency_feedback(RuntimeError("ordinary compile error"))) def test_visual_review_drops_self_declared_non_issues_and_unproven_proportion_guess(self): diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py new file mode 100644 index 0000000..b591ae8 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py @@ -0,0 +1,4859 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import os +import pathlib +import tempfile +import unittest +import zipfile +from unittest import mock + +from openpyxl import load_workbook + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +ENGINE_PATH = pathlib.Path(__file__).with_name("solid_wood_cost_engine.py") +PRICE_PATH = pathlib.Path( + os.environ.get( + "DALONGXIA_TEST_SOLID_WOOD_PRICE_XLSX", + "/Users/a134/Desktop/实木家具材种价格表2026 年 8 月 7 日版本.xlsx", + ) +) +AI3D_PATH = pathlib.Path( + os.environ.get( + "DALONGXIA_TEST_AI_3D_ENGINE_PATH", + str(ROOT / "server_patch" / "ai_3d_model" / "ai_3d_model_engine.py"), + ) +) + + +def load_engine_module(): + spec = importlib.util.spec_from_file_location("solid_wood_cost_test_module", ENGINE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +class SolidWoodCostEngineTest(unittest.TestCase): + def test_model_auth_failure_is_terminal_and_not_retried_as_bad_json(self): + error = self.module.urllib.error.HTTPError( + "https://example.test/chat/completions", 401, "Unauthorized", None, + io.BytesIO(b'{"error":{"code":"invalid_api_key"}}'), + ) + os.environ["DASHSCOPE_API_KEY"] = "invalid-test-key" + body = { + "model": "test-model", + "messages": [{"role": "user", "content": "return json"}], + "response_format": {"type": "json_object"}, + } + with mock.patch.object(self.module.urllib.request, "urlopen", side_effect=error) as opened: + with self.assertRaisesRegex(self.module.SolidWoodCostError, "模型鉴权失败"): + self.engine._request_model_json(body, "测试鉴权", max_attempts=3) + self.assertEqual(1, opened.call_count) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="solid-wood-cost-test-") + os.environ["DALONGXIA_SOLID_WOOD_COST_STATE_DIR"] = self.temp.name + os.environ["DALONGXIA_SOLID_WOOD_PRICE_XLSX"] = str(PRICE_PATH) + os.environ["DALONGXIA_AI_3D_ENGINE_PATH"] = str(AI3D_PATH) + os.environ["DALONGXIA_AI_3D_STATE_DIR"] = str(pathlib.Path(self.temp.name) / "ai3d") + self.module = load_engine_module() + self.engine = self.module.SolidWoodCostEngine() + + def tearDown(self): + self.temp.cleanup() + + def test_price_import_is_versioned_and_idempotent(self): + self.assertEqual(34, len(self.engine._prices())) + version = self.engine._active_price_version() + duplicate = self.engine.import_price_workbook(PRICE_PATH) + self.assertEqual(version["version_id"], duplicate["version_id"]) + self.assertEqual(8000.0, self.engine._price_for("白橡木")["cubic_mid"]) + self.assertEqual(367500.0, self.engine._price_for("大红酸枝")["cubic_mid"]) + + def test_oak_cost_calibration_is_active_and_idempotent(self): + version, prices = self.engine._active_packaging_prices() + self.assertEqual(self.engine.PACKAGING_CALIBRATION_VERSION, version["version_label"]) + self.assertEqual(18.0, prices["epe_sheet"]["unit_price"]) + self.assertEqual(3.2, prices["epe_roll"]["unit_price"]) + self.assertEqual(3.0, prices["carton_basic"]["unit_price"]) + self.assertEqual(4.0, prices["carton_economy"]["unit_price"]) + self.assertEqual(6.0, prices["carton_standard"]["unit_price"]) + self.assertEqual(8.0, prices["carton_reinforced"]["unit_price"]) + self.assertEqual(0.75, self.engine.CARTON_GRADES["company_basic"]["cost_multiplier"]) + self.assertEqual(1.0, self.engine.CARTON_GRADES["company_economy"]["cost_multiplier"]) + self.assertEqual(1.5, self.engine.CARTON_GRADES["company_standard"]["cost_multiplier"]) + self.assertEqual(2.0, self.engine.CARTON_GRADES["company_reinforced"]["cost_multiplier"]) + self.assertTrue(all(float(row["waste_rate"]) == 0.02 for row in prices.values())) + pu = self.engine.FINISH_TEMPLATES["PU清漆"] + self.assertEqual(0.63, pu["coating_labor_h_m2"] + pu["intercoat_sanding_h_m2"] * 2) + self.assertEqual("china-furniture-effective-hour-2026-09-v3", self.engine.LABOR_CALIBRATION_VERSION) + self.module.SolidWoodCostEngine() + with self.engine._connect() as conn: + count = conn.execute( + "SELECT COUNT(*) FROM packaging_price_versions WHERE version_label=?", + (self.engine.PACKAGING_CALIBRATION_VERSION,), + ).fetchone()[0] + self.assertEqual(1, count) + + def test_historical_material_match_rejects_wood_color_and_wrong_species(self): + white_oak = {"main_species": "白橡(北美FAS)"} + false_rows = [ + {"name": "18mm刨花板园野橡木色", "amount": 80.0}, + {"name": "松木脚", "amount": 10.0}, + ] + matched, ratio = self.engine._historical_material_match("SOLID_WOOD", white_oak, false_rows) + self.assertFalse(matched) + self.assertEqual(0.0, ratio) + + rubberwood_rows = [{"name": "15E0级橡胶木原木色+原木色", "amount": 100.0}] + matched, _ = self.engine._historical_material_match("SOLID_WOOD", white_oak, rubberwood_rows) + self.assertFalse(matched) + matched, ratio = self.engine._historical_material_match( + "SOLID_WOOD", {"main_species": "橡胶木(国产)"}, rubberwood_rows + ) + self.assertTrue(matched) + self.assertEqual(1.0, ratio) + + actual_white_oak = [{"name": "北美白橡木FAS级 原木色", "amount": 100.0}] + matched, ratio = self.engine._historical_material_match("SOLID_WOOD", white_oak, actual_white_oak) + self.assertTrue(matched) + self.assertEqual(1.0, ratio) + + def test_assets_accumulate_without_duplicate_upload_loop(self): + project = self.engine._new_project("ctx") + first = [ + {"kind": "image_url", "value": "https://example.test/front.png", "filename": "正面.png"}, + {"kind": "image_url", "value": "https://example.test/side.png", "filename": "侧面.png"}, + ] + self.engine._store_assets(project["project_id"], first) + self.engine._store_assets(project["project_id"], first) + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_url", "value": "https://example.test/open.png", "filename": "开门.png"}, + {"kind": "image_url", "value": "https://example.test/dim.png", "filename": "尺寸图.png"}, + ], + ) + assets = self.engine._assets(project["project_id"]) + self.assertEqual(4, len(assets)) + self.assertEqual(set(self.engine.ROLES), {item["role"] for item in assets}) + + def test_same_annotated_front_image_can_also_fill_dimension_role(self): + project = self.engine._new_project("shared-front-dimension") + shared_value = "data:image/png;base64,SAME-ANNOTATED-FRONT" + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_base64", "value": shared_value, "filename": "120cm柜正视图.png"}, + {"kind": "image_url", "value": "https://example.test/side.png", "filename": "120cm柜侧视图.png"}, + {"kind": "image_url", "value": "https://example.test/third.png", "filename": "120cm柜第三视角.png"}, + {"kind": "image_base64", "value": shared_value, "filename": "120cm柜尺寸图.png"}, + ], + ) + assets = self.engine._assets(project["project_id"]) + self.assertEqual(4, len(assets)) + self.assertEqual(set(self.engine.ROLES), {item["role"] for item in assets}) + # Replaying the same four attachment records remains idempotent. + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_base64", "value": shared_value, "filename": "120cm柜正视图.png"}, + {"kind": "image_base64", "value": shared_value, "filename": "120cm柜尺寸图.png"}, + ], + ) + self.assertEqual(4, len(self.engine._assets(project["project_id"]))) + + def test_vision_json_repairs_missing_commas_without_losing_components(self): + malformed = """ + ```json + { + "product_name": "双门边柜", + "product_type": "边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": true + "components": [ + {"part_id":"TOP-001","name":"顶板","quantity":1,"dimensions_mm":{"x":1200,"y":400,"z":20},"material_group":"主体结构"} + {"part_id":"SIDE-001","name":"侧板","quantity":2,"dimensions_mm":{"x":20,"y":400,"z":760},"material_group":"主体结构"}, + ], + "uncertainties": [] + } + ``` + """ + decoded = self.engine._decode_model_json(malformed) + normalized = self.engine._normalize_analysis(decoded) + self.assertEqual("双门边柜", normalized["product_name"]) + self.assertEqual(2, len(normalized["components"])) + self.assertEqual("SIDE-001", normalized["components"][1]["part_id"]) + + def test_asset_validation_retries_malformed_model_json_without_reupload(self): + project = self.engine._new_project("asset-json-auto-retry") + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_url", "value": "https://example.test/front.png", "filename": "正面.png"}, + {"kind": "image_url", "value": "https://example.test/side.png", "filename": "侧面.png"}, + {"kind": "image_url", "value": "https://example.test/open.png", "filename": "开门.png"}, + {"kind": "image_url", "value": "https://example.test/dimension.png", "filename": "尺寸图.png"}, + ], + ) + assets = self.engine._assets(project["project_id"]) + valid = { + "same_product": True, + "has_doors": True, + "assets": [ + { + "asset_id": item["asset_id"], + "detected_role": item["role"], + "usable": True, + "issues": [], + "confidence": 0.98, + } + for item in assets + ], + } + + class FakeResponse: + def __init__(self, content): + self.content = content + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps({"choices": [{"message": {"content": self.content}}]}).encode() + + os.environ["DASHSCOPE_API_KEY"] = "test-key" + with mock.patch.object( + self.module.urllib.request, + "urlopen", + side_effect=[FakeResponse("{same_product: true, assets: [}"), FakeResponse(json.dumps(valid))], + ) as mocked: + validation = self.engine._validate_assets_multimodal(project["project_id"], assets) + self.assertTrue(validation["ok"]) + self.assertEqual(2, mocked.call_count) + self.assertEqual(4, sum(item["validation_status"] == "accepted" for item in self.engine._assets(project["project_id"]))) + + def test_failed_vision_can_retry_with_saved_images(self): + context_key = self.engine.context_key("retry-conversation", "retry-user", "retry-session") + project = self.engine._new_project(context_key) + images = [ + {"kind": "image_url", "value": f"https://example.test/{role}.png", "filename": f"{role}.png"} + for role in ("front", "side", "open", "dimension") + ] + self.engine._store_assets(project["project_id"], images) + self.engine._update_project(project["project_id"], "failed", "识别失败", error="坏JSON") + self.engine._vision_analyze = lambda assets, message: self.engine._normalize_analysis( + { + "product_name": "测试边柜", + "product_type": "边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 760}}, + ], + } + ) + def accept_assets(project_id, assets, message=""): + with self.engine._connect() as conn: + conn.execute( + "UPDATE project_assets SET validation_status='accepted' WHERE project_id=?", + (project_id,), + ) + return {"ok": True, "missing_roles": []} + self.engine._validate_assets_multimodal = accept_assets + result = self.engine.run( + "继续", + conversation_id="retry-conversation", + user_id="retry-user", + session_key="retry-session", + ) + self.assertEqual("waiting", result["status"]) + self.assertIn("低置信尺寸清单", result["answer"]) + result = self.engine.run( + "同意按整体尺寸补齐基础柜体结构,生成CAD后与原图复核", + conversation_id="retry-conversation", + user_id="retry-user", + session_key="retry-session", + ) + self.assertIn("低置信尺寸清单", result["answer"]) + result = self.engine.run( + "同意对未标注尺寸按CAD装配合理值预估", + conversation_id="retry-conversation", + user_id="retry-user", + session_key="retry-session", + ) + self.assertIn("选择本次评估用途", result["answer"]) + latest = self.engine.latest_project(context_key) + self.assertEqual(project["project_id"], latest["project_id"]) + self.assertEqual("waiting_cost_purpose", latest["stage"]) + + def test_fixture_bom_cost_and_cad_source(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "双门边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "origins_mm": [{"x": 0, "y": 0, "z": 780}], "material_group": "主体结构", "confidence": 1}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 400, "z": 760}, "origins_mm": [{"x": 0, "y": 0, "z": 20}, {"x": 1180, "y": 0, "z": 20}], "material_group": "主体结构", "confidence": 1}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 760}, "origins_mm": [{"x": 20, "y": 391, "z": 20}], "material_group": "背板", "confidence": 1}, + {"part_id": "DOOR-001", "name": "柜门", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 720}, "origins_mm": [{"x": 20, "y": 0, "z": 40}, {"x": 602, "y": 0, "z": 40}], "material_group": "主体结构", "confidence": 1}, + ], + } + ) + project = self.engine._new_project("ctx-cost") + self.engine._save_bom_draft(project["project_id"], analysis) + project = self.engine._update_project( + project["project_id"], + "confirmed", + "confirmed", + analysis=analysis, + selections={ + "main_species": "白橡(北美FAS)", + "aux_species": "桐木", + "finish": "PU清漆", + "batch_qty": 10, + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + }, + ) + quote = self.engine._calculate_quote(project) + self.assertGreater(quote["summary"]["total_mid"], quote["summary"]["wood_mid"]) + self.assertEqual("桐木", next(x for x in quote["material_lines"] if x["name"] == "背板")["species"]) + self.assertAlmostEqual( + quote["summary"]["packaging_cost"], + sum(line["cost"] for line in quote["packaging_bom"]["lines"]), + places=6, + ) + self.assertEqual("拆装", quote["packaging_bom"]["shipment_mode"]) + self.assertEqual("电商快递", quote["packaging_bom"]["shipping_scenario"]) + process_codes = {line["process_code"] for line in quote["process_costing"]["lines"]} + self.assertIn("cnc_joinery", process_codes) + self.assertGreater(quote["production_joinery"]["back_panel_groove"]["count"], 0) + self.assertIn("finishing", process_codes) + self.assertTrue(all(line["hourly_rate"] > 0 for line in quote["process_costing"]["lines"])) + source = self.engine._source_for_components(analysis) + self.engine._load_ai3d()._validate_source(source) + self.assertIn("Color(", source) + self.assertIn(".color =", source) + + def test_bottom_panel_uses_main_species_but_back_and_drawer_bottom_use_auxiliary(self): + selections = {"main_species": "白橡(北美FAS)", "aux_species": "桐木"} + self.assertEqual( + "白橡(北美FAS)", + self.engine._selected_species_for_component({"name": "底板", "material_group": "主体结构"}, selections), + ) + self.assertEqual( + "桐木", + self.engine._selected_species_for_component({"name": "背板", "material_group": "背板"}, selections), + ) + self.assertEqual( + "桐木", + self.engine._selected_species_for_component({"name": "抽屉底板", "material_group": "抽屉底板"}, selections), + ) + + def test_missing_back_panel_blocks_quote_until_user_confirms_structure(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "双门边柜", + "product_type": "边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "material_group": "主体结构"}, + {"part_id": "BOT-001", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "material_group": "主体结构"}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 400, "z": 760}, "material_group": "主体结构"}, + {"part_id": "DOOR-001", "name": "门板", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 720}, "material_group": "主体结构"}, + ], + } + ) + self.assertEqual( + ["back_panel", "door_mechanism", "cad_estimation_consent"], + [item["id"] for item in analysis["structure_questions"]], + ) + unresolved, done = self.engine._resolve_structure(analysis, "主体白橡木,PU清漆") + self.assertFalse(done) + self.assertEqual(analysis["components"], unresolved["components"]) + resolved, done = self.engine._resolve_structure(analysis, "有背板,厚度9mm") + self.assertFalse(done) + back = next(item for item in resolved["components"] if item["part_id"] == "BACK-001") + self.assertEqual({"x": 1160.0, "y": 9.0, "z": 760.0}, back["dimensions_mm"]) + self.assertEqual(1.0, back["confidence"]) + resolved, done = self.engine._resolve_structure(resolved, "柜门是平开门") + self.assertFalse(done) + resolved, done = self.engine._resolve_structure(resolved, "同意按CAD合理值预估") + self.assertTrue(done) + self.assertEqual("hinged", resolved["door_mechanism"]) + + def test_completed_answer_exposes_bom_labor_finish_and_packaging_breakdown(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "双门边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "material_group": "主体结构"}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 760}, "material_group": "背板"}, + {"part_id": "DOOR-001", "name": "门板", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 720}, "material_group": "主体结构"}, + ], + } + ) + project = self.engine._new_project("ctx-answer") + project = self.engine._update_project( + project["project_id"], "confirmed", "confirmed", analysis=analysis, + selections={"main_species": "白橡(北美FAS)", "aux_species": "桐木", "finish": "PU清漆", "packaging": "标准电商", "packaging_rate": 0.07}, + ) + quote = self.engine._calculate_quote(project) + answer = self.engine._completed_answer( + { + "analysis": analysis, + "result": { + "quote": quote, + "files": [ + { + "artifact_role": "bom_component_thumbnail", + "part_id": "TOP-001", + "url": "https://files.example.test/TOP-001.png", + }, + { + "artifact_role": "bom_component_overview", + "url": "https://files.example.test/overview.png", + }, + { + "artifact_role": "assembled_3d", + "mime_type": "model/gltf-binary", + "url": "https://files.example.test/assembled.glb", + }, + { + "artifact_role": "interactive_3d", + "mime_type": "model/gltf-binary", + "url": "https://files.example.test/exploded.glb", + }, + ], + }, + } + ) + for expected in ("完整部件 BOM", "BACK-001", "木工人工", "油漆人工", "包装人工", "工艺路线与人工", "包装 BOM", "油漆与包装核算", "包装综合成本"): + self.assertIn(expected, answer) + self.assertIn("3D与配件预览", answer) + self.assertIn("https://files.example.test/TOP-001.png", answer) + self.assertIn("https://files.example.test/assembled.glb", answer) + self.assertIn("https://files.example.test/exploded.glb", answer) + self.assertLess(answer.index("3D整装成品模型"), answer.index("3D爆炸拆件模型")) + self.assertLess(answer.index("成本摘要"), answer.index("完整部件 BOM")) + self.assertNotIn("包装材料占最终制造成本", answer) + + def test_assembled_exploded_sources_and_drag_manifest_share_named_parts(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "双门边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + { + "part_id": "TOP-001", "name": "顶板", "quantity": 1, + "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, + "origins_mm": [{"x": 0, "y": 0, "z": 780}], + "material_group": "主体结构", + }, + { + "part_id": "DOOR-001", "name": "门板", "quantity": 2, + "dimensions_mm": {"x": 578, "y": 20, "z": 720}, + "origins_mm": [{"x": 20, "y": 0, "z": 40}, {"x": 602, "y": 0, "z": 40}], + "material_group": "主体结构", + }, + ], + } + ) + assembled = self.engine._source_for_components(analysis, layout="assembled") + exploded = self.engine._source_for_components(analysis, layout="exploded") + self.assertIn("Pos(600.0000, 200.0000, 790.0000)", assembled) + self.assertIn("DOOR-001:2", assembled) + self.assertIn("furniture_assembled", assembled) + self.assertIn("furniture_exploded", exploded) + manifest_path = self.engine._write_interaction_manifest( + pathlib.Path(self.temp.name), "wood-test", analysis + ) + manifest = json.loads(pathlib.Path(manifest_path).read_text(encoding="utf-8")) + self.assertEqual("dalongxia.furniture-model-interaction.v3", manifest["schema"]) + self.assertEqual("assembled", manifest["default_variant"]) + self.assertTrue(manifest["capabilities"]["drag_part"]) + self.assertTrue(manifest["capabilities"]["one_click_explode"]) + self.assertTrue(manifest["capabilities"]["one_click_assemble"]) + self.assertEqual("assembled_model_initial_node_transform", manifest["interaction_state"]["transform_basis"]) + self.assertEqual( + ["explode_all", "assemble_all", "packaging_pack", "packaging_explode"], + [item["id"] for item in manifest["actions"]], + ) + self.assertEqual("exploded_offset_mm", manifest["actions"][0]["offset_field"]) + self.assertIsNone(manifest["actions"][1]["offset_field"]) + self.assertEqual("cad_z_mm * 0.001", manifest["coordinates"]["position_mapping"]["glb_y"]) + self.assertEqual("-cad_y_mm * 0.001", manifest["coordinates"]["position_mapping"]["glb_z"]) + self.assertEqual(3, len(manifest["parts"])) + self.assertEqual("DOOR-001:2", manifest["parts"][2]["node_name"]) + self.assertEqual( + {"x": 602.0, "y": 0.0, "z": 40.0}, + manifest["parts"][2]["assembled_position_mm"], + ) + self.assertEqual( + { + axis: round( + manifest["parts"][2]["exploded_position_mm"][axis] + - manifest["parts"][2]["assembled_position_mm"][axis], + 3, + ) + for axis in ("x", "y", "z") + }, + manifest["parts"][2]["exploded_offset_mm"], + ) + + def test_assembled_layout_repairs_incoherent_ai_axes_and_origins(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "双门边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "origins_mm": [{"x": 0, "y": 0, "z": 0}]}, + {"part_id": "BOT-001", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "origins_mm": [{"x": 0, "y": 0, "z": 0}]}, + {"part_id": "SID-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 720, "z": 400}, "origins_mm": [{"x": 0, "y": 0, "z": 0}, {"x": 1180, "y": 0, "z": 0}]}, + {"part_id": "DOOR-001", "name": "门板", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 716}, "origins_mm": [{"x": 111, "y": 0, "z": 0}, {"x": 689, "y": 0, "z": 0}]}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 720}, "origins_mm": [{"x": 20, "y": 0, "z": 0}]}, + {"part_id": "SHELF-001", "name": "搁板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 380, "z": 20}, "origins_mm": [{"x": 20, "y": 0, "z": 0}]}, + {"part_id": "LEG-001", "name": "支腿", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 40}, "origins_mm": [{"x": 0, "y": 0, "z": 0}, {"x": 1160, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 760}, {"x": 1160, "y": 0, "z": 760}]}, + ], + } + ) + instances = {item["node_name"]: item for item in self.engine._component_instances(analysis)} + self.assertEqual({"x": 0.0, "y": 0.0, "z": 780.0}, instances["TOP-001:1"]["assembled_position_mm"]) + self.assertEqual({"x": 0.0, "y": 0.0, "z": 40.0}, instances["BOT-001:1"]["assembled_position_mm"]) + self.assertEqual({"x": 20.0, "y": 400.0, "z": 720.0}, instances["SID-001:1"]["dimensions_mm"]) + self.assertEqual({"x": 0.0, "y": 0.0, "z": 60.0}, instances["SID-001:1"]["assembled_position_mm"]) + self.assertEqual({"x": 0.0, "y": 360.0, "z": 0.0}, instances["LEG-001:3"]["assembled_position_mm"]) + self.assertEqual({"x": 20.0, "y": 10.0, "z": 410.0}, instances["SHELF-001:1"]["assembled_position_mm"]) + for instance in instances.values(): + for axis in ("x", "y", "z"): + self.assertGreaterEqual(instance["assembled_position_mm"][axis], 0.0) + self.assertLessEqual( + instance["assembled_position_mm"][axis] + instance["dimensions_mm"][axis], + analysis["overall_dimensions_mm"][axis] + 0.001, + ) + + def test_batch_setup_amortization_and_packaging_bom_are_traceable(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "双门边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "material_group": "主体结构", "machining": {"holes": 4, "slots": 1}}, + {"part_id": "BOT-001", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "material_group": "主体结构"}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 760}, "material_group": "背板"}, + {"part_id": "DOOR-001", "name": "门板", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 720}, "material_group": "主体结构", "machining": {"holes": 2, "mortise_tenon": 2}}, + ], + } + ) + base = {"main_species": "白橡(北美FAS)", "aux_species": "桐木", "finish": "PU清漆", "shipment_mode": "拆装", "shipping_scenario": "电商快递"} + project = self.engine._new_project("ctx-batch") + project_one = self.engine._update_project(project["project_id"], "confirmed", "one", analysis=analysis, selections={**base, "batch_qty": 1}) + quote_one = self.engine._calculate_quote(project_one) + project_ten = self.engine._update_project(project["project_id"], "confirmed", "ten", analysis=analysis, selections={**base, "batch_qty": 10}) + quote_ten = self.engine._calculate_quote(project_ten) + self.assertLess(quote_ten["summary"]["labor_cost"], quote_one["summary"]["labor_cost"]) + self.assertEqual(0.0, quote_ten["summary"]["packaging_rate"]) + self.assertAlmostEqual( + quote_ten["summary"]["packaging_cost"], + sum(line["cost"] for line in quote_ten["packaging_bom"]["lines"]), + places=6, + ) + project_dark = self.engine._update_project( + project["project_id"], "confirmed", "dark", analysis=analysis, + selections={**base, "main_species": "黑胡桃(北美FAS)", "batch_qty": 10}, + ) + quote_dark = self.engine._calculate_quote(project_dark) + self.assertNotEqual(quote_ten["summary"]["wood_mid"], quote_dark["summary"]["wood_mid"]) + self.assertAlmostEqual(quote_ten["summary"]["packaging_cost"], quote_dark["summary"]["packaging_cost"], places=6) + job_dir = pathlib.Path(self.temp.name) / "export" + job_dir.mkdir() + paths = self.engine._write_quote_files(job_dir, project["project_id"], quote_ten) + self.assertEqual(len(quote_ten["material_lines"]), len(paths["component_image_paths"])) + self.assertTrue(pathlib.Path(paths["component_contact_sheet_path"]).is_file()) + self.assertTrue(all(pathlib.Path(item["path"]).is_file() for item in paths["component_image_paths"])) + workbook = load_workbook(paths["bom_xlsx_path"], data_only=True) + self.assertIn("工艺工时", workbook.sheetnames) + self.assertIn("包装BOM", workbook.sheetnames) + self.assertIn("连接孔槽明细", workbook.sheetnames) + self.assertEqual("配件图", workbook["部件BOM"]["A1"].value) + self.assertEqual(len(quote_ten["material_lines"]), len(workbook["部件BOM"]._images)) + self.assertGreater(workbook["工艺工时"].max_row, 2) + self.assertGreater(workbook["包装BOM"].max_row, 2) + quote_ten["factory_quote"] = self.engine._factory_quote_layer( + quote_ten["summary"]["total_mid"], {"material_structure": "engineered_panel_all"} + ) + quote_ten["wholesale_quote"] = { + "enabled": True, + "base_cost": quote_ten["summary"]["total_mid"], + "bom_factory_quote": quote_ten["factory_quote"]["bom_factory_quote"], + "special_operation_total": 5.0, + "invoice_tax_rate": 0.05, + "invoice_tax_amount": 5.0, + "wholesale_tax_rate": 0.05, + "wholesale_tax_amount": 5.0, + "profit_margin": 0.20, + "final_quote": 150.0, + "formula": "(BOM出厂报价+特殊操作+1688附加税额)/(1-利润空间)", + "special_operations": [], + } + paths = self.engine._write_quote_files(job_dir, project["project_id"], quote_ten) + workbook = load_workbook(paths["bom_xlsx_path"], data_only=True) + self.assertIn("1688批发报价", workbook.sheetnames) + self.assertEqual(150.0, workbook["1688批发报价"]["B13"].value) + + def test_production_condition_parser_and_gate(self): + project = {"selections": {"main_species": "白橡(北美FAS)", "aux_species": "桐木", "finish": "PU清漆"}} + selections = self.engine._parse_selection("生产10件,拆装,中开箱,经济级纸箱,电商快递,2个包装,不用托盘", project) + self.assertTrue(self.engine._production_ready(selections)) + self.assertEqual(10, selections["batch_qty"]) + self.assertEqual("拆装", selections["shipment_mode"]) + self.assertEqual("电商快递", selections["shipping_scenario"]) + self.assertEqual(2, selections["package_count"]) + self.assertEqual("rsc_center_open", selections["packaging_box_style"]) + self.assertEqual("company_economy", selections["carton_grade"]) + self.assertFalse(selections["use_pallet"]) + + def test_partial_production_input_shows_saved_values_and_exact_missing_fields(self): + project = self.engine._new_project("ctx-partial-production") + project = self.engine._update_project( + project["project_id"], + "waiting_production", + "waiting", + selections={ + "main_species": "白橡(北美FAS)", + "aux_species": "桐木", + "finish": "PU清漆", + "batch_qty": 10, + }, + ) + answer = self.engine._production_answer(project) + self.assertIn("批量生产(50件/批)", answer) + self.assertIn("选择包装方式", answer) + self.assertNotIn("打样1件", answer) + self.assertNotIn("小批10件", answer) + + def test_conversation_requires_visible_finish_choice_before_production(self): + context_key = self.engine.context_key("conversation", "user", "session") + project = self.engine._new_project(context_key) + analysis = self.engine._normalize_analysis( + { + "product_name": "无背板边几", + "overall_dimensions_mm": {"x": 800, "y": 400, "z": 760}, + "has_doors": False, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 800, "y": 400, "z": 20}, "material_group": "主体结构"}, + {"part_id": "LEG-001", "name": "腿", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 740}, "material_group": "主体结构"}, + ], + } + ) + self.engine._update_project(project["project_id"], "waiting_material", "waiting", analysis=analysis) + material = self.engine.run( + "主体白橡木,PU清漆", + conversation_id="conversation", user_id="user", session_key="session", + ) + self.assertEqual("waiting", material["status"]) + self.assertIn("选择油漆工艺", material["answer"]) + self.assertIn("未选油漆前不会进入生产包装", material["answer"]) + waiting = self.engine.get_project(project["project_id"]) + self.assertEqual("waiting_finish", waiting["stage"]) + self.assertNotIn("finish", waiting["selections"]) + + finish = self.engine.run( + "使用PU清漆,下一步", + conversation_id="conversation", user_id="user", session_key="session", + ) + self.assertEqual("waiting", finish["status"]) + self.assertIn("确认油漆外观", finish["answer"]) + waiting = self.engine.get_project(project["project_id"]) + self.assertEqual("waiting_finish_details", waiting["stage"]) + details = self.engine.run( + "透明原木色,开放效果,哑光", + conversation_id="conversation", user_id="user", session_key="session", + ) + self.assertIn("选择包装方式", details["answer"]) + self.assertIn("批量生产(50件/批)", details["answer"]) + packaging = self.engine.run( + "拆装包装", + conversation_id="conversation", user_id="user", session_key="session", + ) + self.assertIn("选择包装箱型", packaging["answer"]) + box_style = self.engine.run( + "采用系统推荐箱型:中开箱", + conversation_id="conversation", user_id="user", session_key="session", + ) + self.assertIn("选择纸箱成本等级", box_style["answer"]) + carton_grade = self.engine.run( + "使用经济级纸箱", + conversation_id="conversation", user_id="user", session_key="session", + ) + self.assertIn("最终条件确认", carton_grade["answer"]) + captured = {} + + def fake_build(confirmed_project, progress): + captured.update(confirmed_project["selections"]) + return {"status": "completed", "answer": "ok", "files": []} + + self.engine._build_and_quote = fake_build + completed = self.engine.run( + "确认生成CAD和成本报价", + conversation_id="conversation", user_id="user", session_key="session", + ) + self.assertEqual("completed", completed["status"]) + self.assertEqual(50, captured["batch_qty"]) + self.assertEqual("拆装", captured["shipment_mode"]) + + def test_material_actions_do_not_hide_default_finish(self): + project = self.engine._new_project("ctx-material-actions") + analysis = self.engine._normalize_analysis( + { + "product_name": "边几", + "overall_dimensions_mm": {"x": 800, "y": 400, "z": 760}, + "has_doors": False, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 800, "y": 400, "z": 20}, "material_group": "主体结构"}, + ], + } + ) + project = self.engine._update_project( + project["project_id"], "waiting_material", "waiting", analysis=analysis, + selections={"material_structure": "solid_wood_aux"}, + ) + answer = self.engine._material_answer(project) + self.assertIn("分别选择主体与辅材木种", answer) + self.assertNotIn("主体白橡木,背板和抽屉底板用桐木,PU清漆", answer) + self.assertNotIn('"intent": "confirm_material_finish"', answer) + + def test_material_structure_is_required_and_routes_to_matching_second_step(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "结构选择测试柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": False, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 18}, "material_group": "主体结构"}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 760}, "material_group": "背板"}, + ], + } + ) + + preview_project = self.engine._new_project("material-structure-preview") + preview_project = self.engine._update_project( + preview_project["project_id"], "waiting_material_structure", "waiting", analysis=analysis + ) + preview = self.engine._material_structure_answer(preview_project) + for label in ("纯实木", "主体实木+辅材", "板式家具"): + self.assertIn(label, preview) + self.assertIn("包含背板和抽屉底板", preview) + self.assertNotIn("全部白橡木", preview) + + solid_context = self.engine.context_key("solid-structure", "user", "session") + solid_project = self.engine._new_project(solid_context) + self.engine._update_project( + solid_project["project_id"], "waiting_material_structure", "waiting", analysis=analysis + ) + first = self.engine.run( + "选择纯实木结构,下一步选择木种", + conversation_id="solid-structure", user_id="user", session_key="session", + ) + self.assertIn("为纯实木选择木种", first["answer"]) + saved = self.engine.get_project(solid_project["project_id"]) + self.assertEqual("waiting_material", saved["stage"]) + self.assertEqual("solid_wood_all", saved["selections"]["material_structure"]) + self.assertNotIn("密度板MDF", first["answer"]) + + second = self.engine.run( + "纯实木家具全部木质部件使用白橡木", + conversation_id="solid-structure", user_id="user", session_key="session", + ) + self.assertIn("选择油漆工艺", second["answer"]) + saved = self.engine.get_project(solid_project["project_id"]) + self.assertTrue(saved["selections"]["all_same"]) + self.assertEqual(saved["selections"]["main_species"], saved["selections"]["aux_species"]) + + panel_context = self.engine.context_key("panel-structure", "user", "session") + panel_project = self.engine._new_project(panel_context) + self.engine._update_project( + panel_project["project_id"], "waiting_material_structure", "waiting", analysis=analysis + ) + panel = self.engine.run( + "选择全板式家具,全部木质部件使用实木多层板(胶合板)", + conversation_id="panel-structure", user_id="user", session_key="session", + ) + self.assertIn("确认板材厚度", panel["answer"]) + saved = self.engine.get_project(panel_project["project_id"]) + self.assertEqual("waiting_panel_thickness", saved["stage"]) + self.assertEqual("engineered_panel_all", saved["selections"]["material_structure"]) + self.assertEqual("PLYWOOD", saved["selections"]["panel_code"]) + + auxiliary_context = self.engine.context_key("aux-structure", "user", "session") + auxiliary_project = self.engine._new_project(auxiliary_context) + self.engine._update_project( + auxiliary_project["project_id"], "waiting_material_structure", "waiting", analysis=analysis + ) + auxiliary = self.engine.run( + "选择主体实木加辅材结构,下一步分别选择材种", + conversation_id="aux-structure", user_id="user", session_key="session", + ) + self.assertIn("分别选择主体与辅材木种", auxiliary["answer"]) + self.assertIn("白橡+桐木辅材", auxiliary["answer"]) + self.assertNotIn("密度板MDF", auxiliary["answer"]) + + def test_engineered_panel_price_library_supports_three_material_families(self): + version, prices = self.engine._active_panel_prices() + self.assertEqual("public-benchmark-2026-09", version["version_label"]) + codes = {row["material_code"] for row in prices} + self.assertTrue({"MDF", "PLYWOOD", "PARTICLEBOARD"}.issubset(codes)) + self.assertIn("BACKBOARD", codes) + for code in ("MDF", "PLYWOOD", "PARTICLEBOARD"): + price = self.engine._panel_price_for(code, 18, "ENF") + self.assertEqual(18.0, price["thickness_mm"]) + self.assertGreater(price["price_mid_per_sheet"], 0) + self.assertEqual(1.25, price["grade_factor"]) + + def test_mdf_and_particleboard_expose_12mm_price_and_parse_1_2cm(self): + expected_mid = {"MDF": 65.0, "PARTICLEBOARD": 30.4} + for code, expected in expected_mid.items(): + price = self.engine._panel_price_for(code, 12.0, "E1") + self.assertEqual(12.0, price["thickness_mm"]) + self.assertEqual(expected, price["price_mid_per_sheet"]) + answer = self.engine._panel_thickness_answer( + {"analysis": {"components": []}, "selections": {"panel_code": code}} + ) + self.assertIn("按尺寸图自动识别", answer) + self.assertNotIn("按BOM逐件", answer) + self.assertIn("统一12mm(1.2cm)", answer) + self.assertIn(f"¥{expected:.2f}/张", answer) + + parsed = self.engine._parse_selection( + "密度板统一板厚1.2cm", + {"stage": "waiting_panel_thickness", "selections": {}}, + ) + self.assertEqual("uniform", parsed["panel_thickness_mode"]) + self.assertEqual(12.0, parsed["panel_thickness_mm"]) + parsed_without_unit = self.engine._parse_selection( + "刨花板厚度1.2厚", + {"stage": "waiting_panel_thickness", "selections": {}}, + ) + self.assertEqual(12.0, parsed_without_unit["panel_thickness_mm"]) + + def test_ungraded_mdf_company_prices_are_stored_and_selectable(self): + _version, prices = self.engine._active_panel_prices() + rows = { + float(row["thickness_mm"]): row + for row in prices + if row["material_code"] == "MDF_UNGRADED" + } + self.assertEqual({4.0, 12.0, 15.0, 18.0, 20.0}, set(rows)) + self.assertEqual(41.31, rows[12.0]["price_mid_per_sheet"]) + self.assertEqual(52.09, rows[15.0]["price_mid_per_sheet"]) + self.assertEqual(62.86, rows[18.0]["price_mid_per_sheet"]) + self.assertEqual(70.04, rows[20.0]["price_mid_per_sheet"]) + selected = self.engine._parse_selection( + "选择无环保等级密度板", + { + "stage": "waiting_emission_grade", + "selections": { + "material_structure": "engineered_panel_all", + "material_family": "engineered_panel", + "panel_code": "MDF", + "main_species": "密度板(MDF)", + }, + }, + ) + self.assertEqual("UNGRADED", selected["emission_grade"]) + self.assertEqual("MDF_UNGRADED", selected["panel_code"]) + self.assertIn("无等级(公司普通板)", self.engine._emission_grade_answer({"selections": {"panel_code": "MDF"}})) + + def test_sliding_cabinet_repairs_drawer_bom_deduplicates_doors_and_applies_uniform_12mm(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "带抽屉和滑门柜的储物柜", + "product_type": "板式家具", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "dimension_evidence": { + "x": {"value": 1200, "source": "尺寸图整体宽", "confidence": 0.99}, + "y": {"value": 400, "source": "用户确认柜深", "confidence": 1.0}, + "z": {"value": 850, "source": "尺寸图整体高", "confidence": 0.99}, + }, + "has_doors": True, + "door_mechanism": "sliding", + "door_count": 2, + "drawer_count": 2, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 15}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 15, "y": 400, "z": 850}}, + # The vision model may estimate the same door facade about + # 20 mm taller than the explicitly-labelled sliding door. + {"part_id": "FRONT-001", "name": "前挡板(左侧)", "quantity": 1, "dimensions_mm": {"x": 540, "y": 15, "z": 400}}, + {"part_id": "FRONT-002", "name": "前挡板(右侧)", "quantity": 1, "dimensions_mm": {"x": 600, "y": 15, "z": 400}}, + {"part_id": "FRONT-ALL", "name": "前挡板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 15, "z": 850}}, + {"part_id": "DRAWER-001", "name": "抽屉(左侧)", "quantity": 1, "dimensions_mm": {"x": 540, "y": 360, "z": 150}, "material_group": "抽屉底板"}, + {"part_id": "DRAWER-002", "name": "抽屉(右侧)", "quantity": 1, "dimensions_mm": {"x": 580, "y": 360, "z": 150}, "material_group": "抽屉底板"}, + {"part_id": "DRAWER-SIDE-001", "name": "抽屉侧板", "quantity": 4, "dimensions_mm": {"x": 15, "y": 360, "z": 150}, "material_group": "抽屉结构"}, + {"part_id": "DRAWER-BASE-001", "name": "抽屉底板", "quantity": 2, "dimensions_mm": {"x": 560, "y": 360, "z": 15}, "material_group": "抽屉底板"}, + {"part_id": "DOOR-001", "name": "推拉柜门(左侧)", "quantity": 1, "dimensions_mm": {"x": 540, "y": 15, "z": 850}}, + {"part_id": "DOOR-002", "name": "推拉柜门(右侧)", "quantity": 1, "dimensions_mm": {"x": 600, "y": 15, "z": 850}}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1170, "y": 12, "z": 820}}, + ], + "recognized_hardware": [ + {"name": "柜门铰链", "quantity": 4, "confidence": 0.4}, + {"name": "抽屉拉手", "quantity": 2, "confidence": 0.9}, + ], + "assumptions": [ + "所有板材厚度均为20mm,除非另有说明。", + "推拉门厚度为20mm(常规值),除非另有说明。", + "未明确标注的加工工艺(如封边、钻孔)按常规处理。", + ], + } + ) + names = [item["name"] for item in analysis["components"]] + self.assertFalse(any("前挡板" in name for name in names)) + for token in ("抽屉左帮板", "抽屉右帮板", "抽屉后帮板", "抽屉底板"): + self.assertEqual( + 2, + sum(int(item.get("quantity") or 1) for item in analysis["components"] if token in item["name"]), + ) + self.assertFalse(any(item["name"] == "抽屉侧板" for item in analysis["components"])) + self.assertEqual(2, analysis["drawer_count"]) + self.assertEqual(2, analysis["door_count"]) + self.assertEqual("sliding", analysis["door_mechanism"]) + self.assertFalse(any("铰链" in item["name"] for item in analysis["recognized_hardware"])) + self.assertIn("door_panel_height", [item["id"] for item in analysis["structure_questions"]]) + analysis, done = self.engine._resolve_structure(analysis, "推拉门高54cm") + self.assertFalse(done) + analysis, done = self.engine._resolve_structure(analysis, "同意按整体尺寸补齐基础柜体结构,生成CAD后与原图复核") + self.assertFalse(done) + analysis, done = self.engine._resolve_structure(analysis, "同意按CAD合理值预估") + self.assertTrue(done) + self.assertTrue( + all(item["dimensions_mm"]["z"] == 540.0 for item in analysis["components"] if self.engine._is_door_panel(item)) + ) + + selections = { + "cost_purpose": "概算", + "material_structure": "engineered_panel_all", + "material_family": "engineered_panel", + "panel_code": "MDF", + "panel_code": "MDF_UNGRADED", + "main_species": "无等级密度板(MDF)", + "aux_species": "无等级密度板(MDF)", + "all_same": True, + "panel_thickness_mode": "uniform", + "panel_thickness_mm": 12.0, + "emission_grade": "UNGRADED", + "panel_purchase_state": "prefinished", + "finish": "三聚氰胺双饰面", + "edge_material": "PVC", + "edge_thickness_mm": 0.5, + "edge_code": "PVC_0_5", + "edge_scope": "exposed_edges", + "hardware_grade": "basic", + "batch_qty": 50, + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + } + adjusted = self.engine._analysis_for_selected_thickness(analysis, selections) + self.assertTrue(all(min(item["dimensions_mm"].values()) == 12.0 for item in adjusted["components"])) + self.assertNotIn("所有板材厚度均为20mm", " ".join(adjusted.get("assumptions") or [])) + self.assertNotIn("推拉门厚度为20mm", " ".join(adjusted.get("assumptions") or [])) + self.assertNotIn("未明确标注的加工工艺", " ".join(adjusted.get("assumptions") or [])) + self.assertIn("用户确认所有板材统一12mm", adjusted.get("assumptions") or []) + project = self.engine._new_project("huacheng-sliding-regression") + project = self.engine._update_project(project["project_id"], "confirmed", "confirmed", analysis=adjusted, selections=selections) + quote = self.engine._calculate_quote(project) + self.assertFalse(any(line["name"] == "柜门铰链" for line in quote["hardware_bom"]["lines"])) + track = next(line for line in quote["hardware_bom"]["lines"] if "移门上下轨道" in line["name"]) + self.assertEqual(1.0, track["quantity"]) + slides = next(line for line in quote["hardware_bom"]["lines"] if line["name"] == "抽屉导轨") + self.assertEqual(2.0, slides["quantity"]) + hardware_labor = next( + line for line in quote["process_costing"]["lines"] + if line["process_code"] == "hardware_installation" + ) + self.assertGreater(hardware_labor["cost_per_product"], 0.0) + self.assertIn("移门轨道滑轮1套", hardware_labor["driver"]) + self.assertTrue(quote["panel_edge_costing"]["includes_adhesive"]) + drawer_side_edges = [line for line in quote["material_lines"] if "抽屉左帮板" in line["name"] or "抽屉右帮板" in line["name"]] + self.assertTrue(drawer_side_edges) + self.assertTrue(all(line["edge_length_m"] > 0 for line in drawer_side_edges)) + + def test_drawer_composite_does_not_double_two_real_drawers(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "120厘米推拉餐柜", + "product_type": "板式家具", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "dimension_evidence": { + "x": {"value": 1200, "source": "尺寸图", "confidence": 0.99}, + "y": {"value": 400, "source": "用户确认", "confidence": 1.0}, + "z": {"value": 850, "source": "尺寸图", "confidence": 0.99}, + }, + "has_doors": True, + "door_mechanism": "sliding", + "drawer_count": 2, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 838}}, + {"part_id": "BOTTOM-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1176, "y": 400, "z": 12}}, + {"part_id": "DRAWER-001", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 570, "y": 12, "z": 150}}, + {"part_id": "DRAWER-BOX-001", "name": "抽屉面板箱体", "quantity": 2, "dimensions_mm": {"x": 570, "y": 350, "z": 150}}, + {"part_id": "DOOR-001", "name": "移门", "quantity": 2, "dimensions_mm": {"x": 600, "y": 12, "z": 540}}, + ], + "recognized_hardware": [{"name": "移门上下轨道与滑轮套件", "quantity": 1}], + } + ) + self.assertEqual(2, analysis["drawer_count"]) + self.assertFalse(any("抽屉面板箱体" in item["name"] for item in analysis["components"])) + expected_names = { + "第1个抽屉左帮板(左侧板)", + "第2个抽屉左帮板(左侧板)", + "第1个抽屉右帮板(右侧板)", + "第2个抽屉右帮板(右侧板)", + "第1个抽屉后帮板(后侧板)", + "第2个抽屉后帮板(后侧板)", + "第1个抽屉底板", + "第2个抽屉底板", + } + names = {item["name"] for item in analysis["components"]} + self.assertTrue(expected_names.issubset(names)) + for token in ("抽屉左帮", "抽屉右帮", "抽屉后帮", "抽屉底板"): + self.assertEqual( + 2, + sum(int(item.get("quantity") or 1) for item in analysis["components"] if token in item["name"]), + ) + self.assertFalse(self.engine._deterministic_cad_review_issues(analysis)) + + def test_missing_overall_depth_is_confirmed_in_chat_before_drawer_completion(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "推拉门抽屉柜", + "overall_dimensions_mm": {"x": 1200, "y": 0, "z": 850}, + "dimension_evidence": { + "x": {"value": 1200, "source": "整体宽", "confidence": 0.99}, + "y": {"value": 0, "source": "未标注", "confidence": 0.0}, + "z": {"value": 850, "source": "整体高", "confidence": 0.99}, + }, + "has_doors": True, + "door_mechanism": "sliding", + "drawer_count": 1, + "components": [ + {"part_id": "DRAWER-001", "name": "抽屉面板", "quantity": 1, "dimensions_mm": {"x": 580, "y": 150, "z": 12}}, + {"part_id": "DOOR-001", "name": "推拉门", "quantity": 2, "dimensions_mm": {"x": 600, "y": 12, "z": 540}}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1176, "y": 4, "z": 826}}, + ], + } + ) + self.assertIn("overall_depth", [item["id"] for item in analysis["structure_questions"]]) + self.assertFalse(any("抽屉左帮板" in item["name"] for item in analysis["components"])) + resolved, done = self.engine._resolve_structure(analysis, "柜深40cm") + self.assertFalse(done) + self.assertIn("cad_estimation_consent", [item["id"] for item in resolved["structure_questions"]]) + resolved, done = self.engine._resolve_structure( + resolved, "同意按整体尺寸补齐基础柜体结构,生成CAD后与原图复核" + ) + self.assertFalse(done) + resolved, done = self.engine._resolve_structure(resolved, "同意按CAD合理值预估") + self.assertTrue(done) + self.assertEqual(400.0, resolved["overall_dimensions_mm"]["y"]) + self.assertTrue(any("抽屉左帮板" in item["name"] for item in resolved["components"])) + + def test_low_confidence_dimensions_require_explicit_cad_estimation_consent(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "带抽屉书桌", + "product_type": "书桌", + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 850}, + "dimension_evidence": { + "x": {"value": 900, "source": "尺寸图整体宽", "confidence": 0.99}, + "y": {"value": 400, "source": "尺寸图整体深", "confidence": 0.99}, + "z": {"value": 850, "source": "尺寸图整体高", "confidence": 0.99}, + }, + "drawer_count": 1, + "components": [ + { + "part_id": "DRAWER-FRONT-001", "name": "抽屉面板", "quantity": 1, + "dimensions_mm": {"x": 400, "y": 12, "z": 150}, + "dimension_evidence": { + "x": {"source": "尺寸图抽面宽", "confidence": 0.95}, + "y": {"source": "尺寸图板厚", "confidence": 0.95}, + "z": {"source": "尺寸图抽面高", "confidence": 0.95}, + }, + } + ], + } + ) + question = next(item for item in analysis["structure_questions"] if item["id"] == "cad_estimation_consent") + self.assertTrue(any("抽屉左帮" in item["label"] for item in question["items"])) + unchanged, done = self.engine._resolve_structure(analysis, "继续") + self.assertFalse(done) + self.assertFalse((unchanged.get("estimation_consent") or {}).get("approved")) + resolved, done = self.engine._resolve_structure(analysis, "同意对未标注尺寸按CAD装配合理值预估") + self.assertTrue(done) + self.assertTrue(resolved["estimation_consent"]["approved"]) + + def test_single_dimension_supplement_keeps_other_accepted_assets(self): + project = self.engine._new_project("low-confidence-supplement") + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_url", "value": f"https://example.test/{role}.png", "filename": f"{name}.png"} + for role, name in (("front", "正面"), ("side", "侧面"), ("open", "开门"), ("dimension", "尺寸图")) + ], + ) + with self.engine._connect() as conn: + conn.execute("UPDATE project_assets SET validation_status='accepted' WHERE project_id=?", (project["project_id"],)) + self.engine._request_dimension_supplement(project["project_id"]) + assets = self.engine._assets(project["project_id"]) + self.assertEqual(3, sum(item["validation_status"] == "accepted" for item in assets)) + rejected = next(item for item in assets if item["role"] == "dimension") + self.assertEqual("rejected", rejected["validation_status"]) + self.assertIn("单张替换", "".join(rejected["validation"]["issues"])) + + def test_cad_review_deterministically_blocks_missing_drawer_parts_and_sliding_hinges(self): + analysis = { + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 850}, + "door_mechanism": "sliding", + "drawer_count": 1, + "recognized_hardware": [{"name": "柜门铰链", "quantity": 2}], + "components": [ + {"part_id": "DRAWER-F-001", "name": "抽屉面板", "quantity": 1, "dimensions_mm": {"x": 400, "y": 12, "z": 150}}, + {"part_id": "DOOR-001", "name": "推拉门", "quantity": 2, "dimensions_mm": {"x": 450, "y": 12, "z": 540}}, + ], + } + issues = self.engine._deterministic_cad_review_issues(analysis) + codes = [item["code"] for item in issues] + self.assertEqual(4, codes.count("drawer_part_missing")) + self.assertIn("sliding_door_hinge_conflict", codes) + self.assertTrue(all(item["requires_user_confirmation"] for item in issues)) + + def test_cad_review_blocks_excess_drawer_parts_and_composite_placeholder(self): + analysis = { + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 850}, + "door_mechanism": "none", + "drawer_count": 2, + "recognized_hardware": [], + "components": [ + {"part_id": "DRAWER-F", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 420, "y": 12, "z": 150}}, + {"part_id": "DRAWER-BOX", "name": "抽屉面板箱体", "quantity": 2, "dimensions_mm": {"x": 420, "y": 350, "z": 150}}, + {"part_id": "DRAWER-L", "name": "抽屉左帮板", "quantity": 4, "dimensions_mm": {"x": 350, "y": 12, "z": 120}}, + {"part_id": "DRAWER-R", "name": "抽屉右帮板", "quantity": 2, "dimensions_mm": {"x": 350, "y": 12, "z": 120}}, + {"part_id": "DRAWER-B", "name": "抽屉后帮板", "quantity": 2, "dimensions_mm": {"x": 396, "y": 12, "z": 120}}, + {"part_id": "DRAWER-BOTTOM", "name": "抽屉底板", "quantity": 2, "dimensions_mm": {"x": 396, "y": 350, "z": 12}}, + ], + } + issues = self.engine._deterministic_cad_review_issues(analysis) + codes = [item["code"] for item in issues] + self.assertIn("drawer_composite_not_split", codes) + self.assertIn("drawer_part_excess", codes) + excess = next(item for item in issues if item["code"] == "drawer_part_excess") + self.assertIn("应为2件", excess["description"]) + + def test_chat_api_keeps_waiting_cad_review_in_cost_skill(self): + candidates = ( + ENGINE_PATH.parent.parent.joinpath("chat_api.production.base.py"), + ENGINE_PATH.parent.parent.joinpath("chat_api.py"), + ) + chat_api_path = next((path for path in candidates if path.exists()), None) + self.assertIsNotNone(chat_api_path) + chat_api_source = chat_api_path.read_text(encoding="utf-8") + active_stages = chat_api_source.split("SOLID_WOOD_ACTIVE_STAGES = {", 1)[1].split("}", 1)[0] + self.assertIn('"waiting_cad_review"', active_stages) + + def test_cad_review_retries_an_empty_but_parseable_model_response(self): + analysis = { + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 850}, + "door_mechanism": "none", + "drawer_count": 0, + "recognized_hardware": [], + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 900, "y": 400, "z": 12}} + ], + } + valid = { + "ok": True, + "confidence": 0.93, + "issues": [], + "checks": { + "assembly_reasonable": True, + "drawer_complete": True, + "door_hardware_consistent": True, + "no_obvious_duplicate": True, + }, + } + with mock.patch.object(self.engine, "_request_model_json", side_effect=[{}, valid]) as requested: + review = self.engine._review_cad_model(analysis, {}) + self.assertEqual(2, requested.call_count) + self.assertTrue(review["response_complete"]) + self.assertEqual(2, review["attempts"]) + self.assertTrue(review["ok"]) + + def test_v61_cad_review_checks_follow_adjudicated_issue_result(self): + analysis = self.engine._normalize_analysis( + { + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "has_doors": True, + "door_mechanism": "sliding", + "door_count": 2, + "door_dimensions_mm": {"leaf_widths": [600, 600], "leaf_height": 540}, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "BOT", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SL", "name": "左侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 400, "z": 850}}, + {"part_id": "SR", "name": "右侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 400, "z": 850}}, + {"part_id": "DL", "name": "柜门(左)", "quantity": 1, "dimensions_mm": {"x": 600, "y": 12, "z": 540}, "dimension_evidence": {"source": "尺寸图确认"}}, + {"part_id": "DR", "name": "柜门(右)", "quantity": 1, "dimensions_mm": {"x": 600, "y": 12, "z": 540}, "dimension_evidence": {"source": "尺寸图确认"}}, + ], + } + ) + analysis = self.engine._analysis_for_selected_thickness( + analysis, + {"material_family": "engineered_panel", "panel_thickness_mode": "uniform", "panel_thickness_mm": 12.0}, + ) + raw = { + "ok": True, + "confidence": 0.9, + "issues": [{ + "code": "door_position", + "severity": "warning", + "component": "柜门", + "description": "柜门装配位置疑似干涉", + "evidence": "缩略图观察", + "suggested_question": "请确认门片位置", + "requires_user_confirmation": True, + "confidence": 0.8, + }], + "checks": { + "assembly_reasonable": False, + "drawer_complete": True, + "door_hardware_consistent": True, + "no_obvious_duplicate": True, + }, + } + with mock.patch.object(self.engine, "_request_model_json", return_value=raw): + review = self.engine._review_cad_model(analysis, {}) + self.assertTrue(review["ok"]) + self.assertTrue(review["checks"]["assembly_reasonable"]) + self.assertEqual(["assembly_reasonable"], review["overridden_checks"]) + self.assertFalse(review["raw_checks"]["assembly_reasonable"]) + self.assertEqual(1, len(review["ignored_ai_issues"])) + + def test_cad_review_compares_original_assets_with_cad_and_requires_full_checks(self): + analysis = { + "product_name": "测试桌", + "product_type": "书桌", + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 750}, + "door_mechanism": "none", + "drawer_count": 0, + "recognized_hardware": [], + "components": [ + {"part_id": "TOP-001", "name": "桌面", "quantity": 1, "dimensions_mm": {"x": 900, "y": 400, "z": 12}} + ], + } + valid = { + "ok": True, + "confidence": 0.94, + "issues": [], + "view_findings": [{ + "asset_id": 1, + "declared_role": "front", + "observed_role": "front", + "confidence": 0.94, + "complete_product_front_visible": True, + "count_confidence": 0.94, + "visible_counts": {"drawers": 0, "doors": 0, "shelves": None}, + "overall_dimensions_mm": {"x": 0, "y": 0, "z": 0}, + "dimension_confidence": 0.0, + "evidence": "完整桌体正面可见", + }], + "checks": { + "source_images_consistent": True, + "required_parts_complete": True, + "assembly_reasonable": True, + "drawer_complete": True, + "door_hardware_consistent": True, + "no_obvious_duplicate": True, + }, + } + assets = [{ + "asset_id": 1, "role": "front", "filename": "front.png", "kind": "image_base64", + "value": "data:image/png;base64,AA==", "validation_status": "accepted", + }] + with mock.patch.object(self.engine, "_request_model_json", return_value=valid) as requested: + review = self.engine._review_cad_model(analysis, {}, assets) + content = requested.call_args.args[0]["messages"][0]["content"] + self.assertTrue(any(item.get("type") == "image_url" for item in content)) + self.assertEqual(1, review["source_asset_count"]) + self.assertEqual(1, review["individual_source_images_sent"]) + self.assertTrue(review["response_complete"]) + + def test_user_topology_override_removes_fictional_doors_and_keeps_vertical_drawers(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "开放书架", + "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "has_doors": True, + "door_count": 2, + "drawer_count": 2, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 300, "z": 20}}, + {"part_id": "BOTTOM", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 440, "y": 300, "z": 20}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 300, "z": 1600}}, + {"part_id": "SHELF", "name": "层板", "quantity": 1, "dimensions_mm": {"x": 440, "y": 280, "z": 20}}, + {"part_id": "DOOR", "name": "柜门", "quantity": 2, "dimensions_mm": {"x": 220, "y": 20, "z": 700}}, + {"part_id": "FACADE", "name": "正面轮廓板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 20, "z": 1600}}, + {"part_id": "DRAWER", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 220, "y": 20, "z": 180}}, + ], + } + ) + corrected = self.engine._apply_user_structure_overrides( + analysis, + "这是完全开放书架,没有柜门,前方没有整高板件;共5块层板;底部只有2个上下叠放抽屉;无背板。", + ) + self.assertFalse(corrected["has_doors"]) + self.assertEqual("none", corrected["door_mechanism"]) + self.assertEqual(0, self.engine._door_panel_count(corrected["components"])) + self.assertFalse(any("正面轮廓板" in item["name"] for item in corrected["components"])) + self.assertEqual(5, sum(item["quantity"] for item in corrected["components"] if item["name"] == "层板")) + self.assertEqual("vertical", corrected["user_topology_constraints"]["drawer_layout"]) + self.assertEqual(2, corrected["user_topology_constraints"]["expected_counts"]["drawers"]) + for item in corrected["components"]: + if self.engine._is_drawer_front(item): + self.assertAlmostEqual(440.0, item["dimensions_mm"]["x"]) + + def test_uniform_thickness_reflows_cabinet_origins_inside_overall_envelope(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "推拉餐柜", + "product_type": "餐边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "has_doors": True, + "door_mechanism": "sliding", + "door_count": 2, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "BOTTOM", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 400, "z": 810}}, + {"part_id": "DOOR", "name": "推拉门", "quantity": 2, "dimensions_mm": {"x": 588, "y": 20, "z": 540}}, + ], + } + ) + adjusted = self.engine._analysis_for_selected_thickness( + analysis, + {"material_family": "engineered_panel", "panel_code": "MDF", "panel_thickness_mode": "uniform", "panel_thickness_mm": 12}, + ) + side = next(item for item in adjusted["components"] if item["name"] == "侧板") + self.assertEqual([0.0, 1188.0], [origin["x"] for origin in side["origins_mm"]]) + self.assertEqual([12.0, 12.0], [origin["z"] for origin in side["origins_mm"]]) + for instance in self.engine._component_instances(adjusted): + for axis in ("x", "y", "z"): + self.assertLessEqual( + instance["assembled_position_mm"][axis] + instance["dimensions_mm"][axis], + adjusted["overall_dimensions_mm"][axis] + 1e-6, + ) + + def test_sliding_user_topology_removes_full_facade_and_names_internal_dividers(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "推拉餐柜", "product_type": "柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "has_doors": True, "door_mechanism": "sliding", "door_count": 2, "drawer_count": 2, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "BOTTOM", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 826}}, + {"part_id": "DOOR", "name": "推拉门", "quantity": 2, "dimensions_mm": {"x": 600, "y": 12, "z": 540}}, + {"part_id": "FRONT", "name": "前挡板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 12, "z": 850}}, + {"part_id": "SHELF", "name": "层板", "quantity": 1, "dimensions_mm": {"x": 576, "y": 388, "z": 12}}, + {"part_id": "DIV", "name": "竖隔板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 400, "z": 500}}, + {"part_id": "SUPPORT", "name": "通宽横隔板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 12, "z": 380}}, + {"part_id": "CENTER", "name": "中隔板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 380, "z": 200}}, + {"part_id": "DRAWER", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 576, "y": 12, "z": 150}}, + ], + } + ) + corrected = self.engine._apply_user_structure_overrides( + analysis, + "2扇左右推拉门,2个左右并排的完整抽屉,共2块层板,下柜中间1块竖隔板,抽屉下方1块通宽横隔板,两个抽屉之间1块竖向中隔板,1块1200×100mm踢脚板。", + ) + names = [item["name"] for item in corrected["components"]] + self.assertNotIn("前挡板", names) + self.assertIn("下柜中间竖隔板", names) + self.assertIn("抽屉下方通宽横隔板", names) + self.assertIn("两个抽屉之间竖向中隔板", names) + self.assertIn("踢脚板", names) + self.assertEqual({"doors": 2, "drawers": 2, "shelves": 2}, corrected["user_topology_constraints"]["expected_counts"]) + + def test_cad_review_preserves_accepted_role_and_user_confirmed_counts(self): + analysis = { + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "door_count": 0, + "drawer_count": 2, + "user_topology_constraints": {"source": "用户对话明确确认", "expected_counts": {"doors": 0, "drawers": 2}}, + } + assets = [{"asset_id": 11, "role": "front"}, {"asset_id": 12, "role": "side"}] + raw = {"view_findings": [ + {"asset_id": 11, "observed_role": "front", "confidence": 0.96, "complete_product_front_visible": True, "count_confidence": 0.96, "visible_counts": {"drawers": 3, "doors": 1}, "evidence": "外观计数不确定"}, + {"asset_id": 12, "observed_role": "front", "confidence": 0.93, "complete_product_front_visible": False, "visible_counts": {}, "evidence": "复核模型误判视角"}, + ]} + issues = self.engine._cad_review_view_issues(analysis, raw, assets) + self.assertFalse(any(item["code"].endswith("count_mismatch") for item in issues)) + role_issue = next(item for item in issues if item["code"] == "source_view_role_mismatch") + self.assertEqual("info", role_issue["severity"]) + self.assertFalse(role_issue["requires_user_confirmation"]) + + def test_user_required_topology_is_a_deterministic_cad_gate(self): + analysis = { + "product_name": "餐边柜", "product_type": "柜", "has_doors": False, + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "components": [], "recognized_hardware": [], "drawer_count": 0, + "user_topology_constraints": {"required_parts": ["踢脚板"], "expected_counts": {"shelves": 2}}, + } + codes = {item["code"] for item in self.engine._deterministic_cad_review_issues(analysis)} + self.assertIn("user_required_part_missing", codes) + self.assertIn("user_topology_shelves_count_mismatch", codes) + + def test_cad_review_blocks_per_view_role_and_drawer_count_conflicts(self): + analysis = { + "product_name": "带抽屉书柜", + "product_type": "书柜", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "door_mechanism": "none", + "door_count": 0, + "drawer_count": 5, + "recognized_hardware": [], + "components": [ + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 300, "z": 1560}}, + {"part_id": "DRAWER-F", "name": "抽屉面板", "quantity": 5, "dimensions_mm": {"x": 220, "y": 12, "z": 140}}, + {"part_id": "DRAWER-L", "name": "抽屉左帮板", "quantity": 5, "dimensions_mm": {"x": 12, "y": 250, "z": 100}}, + {"part_id": "DRAWER-R", "name": "抽屉右帮板", "quantity": 5, "dimensions_mm": {"x": 12, "y": 250, "z": 100}}, + {"part_id": "DRAWER-B", "name": "抽屉后帮板", "quantity": 5, "dimensions_mm": {"x": 196, "y": 12, "z": 100}}, + {"part_id": "DRAWER-BOTTOM", "name": "抽屉底板", "quantity": 5, "dimensions_mm": {"x": 196, "y": 250, "z": 3}}, + ], + } + assets = [ + {"asset_id": 11, "role": "front", "filename": "front.png", "kind": "image_base64", "value": "data:image/png;base64,AA==", "validation_status": "accepted"}, + {"asset_id": 12, "role": "side", "filename": "side.png", "kind": "image_base64", "value": "data:image/png;base64,AA==", "validation_status": "accepted"}, + ] + raw = { + "ok": True, + "confidence": 0.95, + "issues": [], + "view_findings": [ + {"asset_id": 11, "declared_role": "front", "observed_role": "front", "confidence": 0.96, "complete_product_front_visible": True, "count_confidence": 0.96, "visible_counts": {"drawers": 2, "doors": 0}, "evidence": "完整正面只显示两个抽屉"}, + {"asset_id": 12, "declared_role": "side", "observed_role": "front", "confidence": 0.93, "complete_product_front_visible": False, "visible_counts": {}, "evidence": "画面没有侧面深度轮廓"}, + ], + "checks": { + "source_images_consistent": False, + "required_parts_complete": True, + "assembly_reasonable": True, + "drawer_complete": True, + "door_hardware_consistent": True, + "no_obvious_duplicate": True, + }, + } + with mock.patch.object(self.engine, "_request_model_json", return_value=raw): + review = self.engine._review_cad_model(analysis, {}, assets) + codes = {item["code"] for item in review["issues"]} + self.assertIn("source_view_role_mismatch", codes) + role_issue = next(item for item in review["issues"] if item["code"] == "source_view_role_mismatch") + self.assertEqual("info", role_issue["severity"]) + self.assertIn("source_drawers_count_mismatch", codes) + self.assertFalse(review["ok"]) + self.assertTrue(review["requires_confirmation"]) + + def test_drawer_structural_panel_names_never_inflate_front_count(self): + components = [ + {"name": "抽屉面板", "quantity": 2}, + {"name": "抽屉面板后板", "quantity": 2}, + {"name": "抽屉面板下方通宽横隔板", "quantity": 1}, + {"name": "两个抽屉面板之间竖向中隔板", "quantity": 1}, + {"name": "抽屉左帮板", "quantity": 2}, + {"name": "抽屉右帮板", "quantity": 2}, + {"name": "抽屉底板", "quantity": 2}, + ] + self.assertEqual(2, self.engine._drawer_front_count(components)) + + def test_declared_drawer_count_deduplicates_two_model_front_sets(self): + components = [ + {"part_id": "A", "name": "抽屉面板-1", "quantity": 1}, + {"part_id": "B", "name": "抽屉面板-2", "quantity": 1}, + {"part_id": "C", "name": "抽屉面板-1重复", "quantity": 1}, + {"part_id": "D", "name": "抽屉面板-2重复", "quantity": 1}, + {"part_id": "S", "name": "抽屉下方通宽横隔板", "quantity": 1}, + ] + limited = self.engine._limit_drawer_fronts(components, 2) + self.assertEqual(2, self.engine._drawer_front_count(limited)) + self.assertTrue(any(item["part_id"] == "S" for item in limited)) + + def test_partition_with_bad_door_evidence_is_not_counted_as_door(self): + partition = { + "name": "竖向隔板", + "material_group": "主体结构", + "dimension_evidence": {"x": {"source": "door leaf OCR误关联", "confidence": 0.9}}, + } + self.assertFalse(self.engine._is_door_panel(partition)) + + def test_grouped_panel_hardware_and_partial_weld_remove_failed_benchmark_biases(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "板材金属办公桌", + "product_type": "书桌", + "overall_dimensions_mm": {"x": 1200, "y": 600, "z": 750}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "桌面", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 600, "z": 20}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 600, "z": 730}}, + {"part_id": "FRONT-001", "name": "前挡板", "quantity": 1, "dimensions_mm": {"x": 400, "y": 20, "z": 730}}, + {"part_id": "DRAWER-001", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 300, "y": 300, "z": 12}}, + {"part_id": "DOOR-001", "name": "柜门面板", "quantity": 1, "dimensions_mm": {"x": 300, "y": 300, "z": 12}}, + {"part_id": "INTERIOR-001", "name": "抽屉底板", "quantity": 2, "dimensions_mm": {"x": 280, "y": 280, "z": 12}}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 726}}, + {"part_id": "STAND-001", "name": "金属桌腿", "quantity": 2, "dimensions_mm": {"x": 50, "y": 50, "z": 730}}, + {"part_id": "BRACE-001", "name": "金属横撑", "quantity": 2, "dimensions_mm": {"x": 50, "y": 50, "z": 400}}, + ], + "assumptions": ["所有木质部件均采用相同的白橡木材质。"], + } + ) + project = self.engine._new_project("blind-benchmark-regression") + selections = { + "cost_purpose": "实际成本复核", + "material_structure": "engineered_panel_metal", "material_family": "engineered_panel", + "panel_code": "MDF", "main_species": "密度板(MDF)", "aux_species": "密度板(MDF)", "all_same": True, + "panel_thickness_mode": "grouped", "panel_main_thickness_mm": 15.0, "panel_thin_thickness_mm": 2.5, + "emission_grade": "E1", "panel_purchase_state": "prefinished", + "edge_material": "PVC", "edge_thickness_mm": 0.5, "edge_code": "PVC_0_5", "edge_scope": "exposed_edges", + "finish": "三聚氰胺双饰面", "hardware_grade": "basic", + "metal_component_scope": "custom", "metal_component_keywords": ["金属桌腿", "金属横撑"], + "metal_material_code": "Q235_BLACK", "metal_material_name": "Q235碳钢黑管", + "metal_profile_shape": "rect_tube", "metal_profile_width_mm": 40.0, "metal_profile_height_mm": 20.0, + "metal_profile_diameter_mm": 0.0, "metal_profile_display": "40×20mm矩形管", "metal_wall_thickness_mm": 0.6, + "metal_joining": "weld_co2", "metal_joint_count": 8, "metal_weld_coverage": 0.35, "metal_grind_coverage": 0.5, + "metal_finish": "phosphating_powder", "batch_qty": 100, + "shipment_mode": "拆装", "shipping_scenario": "电商快递", + } + project = self.engine._update_project(project["project_id"], "confirmed", "confirmed", analysis=analysis, selections=selections) + with mock.patch.object(self.engine, "_historical_bom_benchmark", side_effect=AssertionError("blind estimate must not query company product BOM")): + quote = self.engine._calculate_quote(project) + main = next(line for line in quote["material_lines"] if line["part_id"] == "TOP-001") + back = next(line for line in quote["material_lines"] if line["part_id"] == "BACK-001") + self.assertEqual(15.0, main["standard_thickness_mm"]) + self.assertEqual(0.76, main["procurement_price_factor"]) + self.assertEqual(0.93, main["nesting_yield_rate"]) + self.assertEqual("BACKBOARD", back["panel_code"]) + self.assertEqual(2.5, back["standard_thickness_mm"]) + self.assertLess(quote["summary"]["hardware"], 20.0) + self.assertFalse(any("预留" in line["name"] for line in quote["hardware_bom"]["lines"])) + self.assertEqual("disabled_for_blind_estimation", quote["historical_bom_benchmark"]["status"]) + self.assertAlmostEqual(0.35, quote["metal_costing"]["weld_coverage"]) + self.assertAlmostEqual(quote["metal_costing"]["weld_length_m"] * 0.5, quote["metal_costing"]["grind_length_m"]) + self.assertFalse(any("白橡" in value for value in quote["assumptions"])) + self.assertFalse(any("统一0mm" in value for value in quote["assumptions"])) + self.assertTrue(any("主体15mm、背板和抽底2.5mm" in value for value in quote["assumptions"])) + self.assertLess(quote["summary"]["total_mid"], 190.0) + + parsed = self.engine._parse_selection( + "主体15mm,背板和抽屉底板2.5mm", + {"stage": "waiting_panel_thickness", "selections": {}}, + ) + self.assertEqual("grouped", parsed["panel_thickness_mode"]) + self.assertEqual(15.0, parsed["panel_main_thickness_mm"]) + self.assertEqual(2.5, parsed["panel_thin_thickness_mm"]) + + def test_final_confirmation_exposes_every_applicable_step_and_single_step_edit(self): + conversation_id, user_id, session_key = "edit-all", "tester", "session" + context_key = self.engine.context_key(conversation_id, user_id, session_key) + analysis = self.engine._normalize_analysis( + { + "product_name": "板金边柜", + "product_type": "边柜", + "overall_dimensions_mm": {"x": 1000, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1000, "y": 400, "z": 15}}, + {"part_id": "DOOR-001", "name": "门板", "quantity": 1, "dimensions_mm": {"x": 500, "y": 15, "z": 700}}, + {"part_id": "LEG-001", "name": "金属支腿", "quantity": 4, "dimensions_mm": {"x": 25, "y": 25, "z": 150}}, + ], + } + ) + analysis["process_evidence"] = {"requires_confirmation": True, "totals": {}} + selections = { + "cost_purpose": "概算", "material_structure": "engineered_panel_metal", "material_family": "engineered_panel", + "panel_code": "MDF", "main_species": "密度板(MDF)", "aux_species": "密度板(MDF)", "all_same": True, + "panel_thickness_mode": "grouped", "panel_main_thickness_mm": 15.0, "panel_thin_thickness_mm": 2.5, + "emission_grade": "E1", "panel_purchase_state": "prefinished", "finish": "三聚氰胺双饰面", + "edge_material": "PVC", "edge_thickness_mm": 0.5, "edge_code": "PVC_0_5", "edge_scope": "exposed_edges", + "hardware_grade": "basic", "metal_component_scope": "custom", "metal_component_keywords": ["支腿"], + "metal_material_code": "Q235_BLACK", "metal_material_name": "Q235碳钢黑管", + "metal_profile_shape": "square_tube", "metal_profile_width_mm": 25.0, "metal_profile_height_mm": 25.0, + "metal_profile_diameter_mm": 0.0, "metal_profile_display": "25×25mm方管", "metal_wall_thickness_mm": 1.2, + "metal_joining": "weld_co2", "metal_joint_count": 8, "metal_weld_coverage": 0.35, "metal_grind_coverage": 0.5, + "metal_finish": "phosphating_powder", "process_features_confirmed": True, "carving_confirmed": False, + "batch_qty": 10, "shipment_mode": "拆装", "shipping_scenario": "电商快递", + "packaging_box_style": "rsc_center_open", + "carton_grade": "company_economy", + } + project = self.engine._new_project(context_key) + project = self.engine._update_project( + project["project_id"], "waiting_confirmation", "waiting", analysis=analysis, selections=selections, + result={"quote": {"stale": True}}, + ) + answer = self.engine._confirmation_answer(project) + for label in ("修改基础用材", "修改板材参数", "修改饰面五金", "修改金属参数", "修改包装方式", "修改包装箱型"): + self.assertIn(label, answer) + self.assertNotIn("修改生产数量", answer) + + panel_menu = self.engine.run( + "打开修改菜单:panel", + conversation_id=conversation_id, user_id=user_id, session_key=session_key, + ) + self.assertEqual("waiting", panel_menu["status"]) + for label in ("修改板厚", "修改环保等级", "修改采购状态", "修改封边材质", "修改封边厚度", "修改封边范围", "返回报价确认"): + self.assertIn(label, panel_menu["answer"]) + + edited = self.engine.run( + "修改步骤:板材厚度", + conversation_id=conversation_id, user_id=user_id, session_key=session_key, + ) + self.assertEqual("waiting", edited["status"]) + self.assertIn("确认板材厚度", edited["answer"]) + saved = self.engine.get_project(project["project_id"]) + self.assertEqual("waiting_panel_thickness", saved["stage"]) + self.assertNotIn("panel_thickness_mode", saved["selections"]) + self.assertEqual("E1", saved["selections"]["emission_grade"]) + self.assertEqual("basic", saved["selections"]["hardware_grade"]) + self.assertEqual({}, saved["result"]) + + returned = self.engine.run( + "统一板厚12mm", + conversation_id=conversation_id, user_id=user_id, session_key=session_key, + ) + self.assertEqual("waiting", returned["status"]) + self.assertIn("最终条件确认", returned["answer"]) + saved = self.engine.get_project(project["project_id"]) + self.assertEqual("waiting_confirmation", saved["stage"]) + self.assertEqual(12.0, saved["selections"]["panel_thickness_mm"]) + self.assertEqual("E1", saved["selections"]["emission_grade"]) + + def test_panel_conversation_confirms_thickness_grade_and_edge_before_finish(self): + context_key = self.engine.context_key("panel-conversation", "panel-user", "panel-session") + project = self.engine._new_project(context_key) + analysis = self.engine._normalize_analysis( + { + "product_name": "板式边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": False, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 18}, "material_group": "主体结构"}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 760}, "material_group": "背板"}, + ], + } + ) + self.engine._update_project(project["project_id"], "waiting_material", "waiting", analysis=analysis) + material = self.engine.run( + "主体使用密度板MDF", + conversation_id="panel-conversation", user_id="panel-user", session_key="panel-session", + ) + self.assertEqual("waiting", material["status"]) + self.assertIn("确认板材厚度", material["answer"]) + self.assertEqual("waiting_panel_thickness", self.engine.get_project(project["project_id"])["stage"]) + + specs = self.engine.run( + "按BOM厚度,ENF级,2mm ABS四边封边", + conversation_id="panel-conversation", user_id="panel-user", session_key="panel-session", + ) + self.assertEqual("waiting", specs["status"]) + self.assertIn("确认板材采购状态", specs["answer"]) + saved = self.engine.get_project(project["project_id"]) + self.assertEqual("waiting_panel_purchase", saved["stage"]) + self.assertEqual("ENF", saved["selections"]["emission_grade"]) + self.assertEqual("ABS_2", saved["selections"]["edge_code"]) + + purchase = self.engine.run( + "采购成品双饰面板", + conversation_id="panel-conversation", user_id="panel-user", session_key="panel-session", + ) + self.assertIn("选择饰面与油漆工艺", purchase["answer"]) + finish = self.engine.run( + "使用三聚氰胺双饰面,下一步", + conversation_id="panel-conversation", user_id="panel-user", session_key="panel-session", + ) + self.assertIn("选择包装方式", finish["answer"]) + self.assertIn("批量生产(50件/批)", finish["answer"]) + + def test_particleboard_quote_includes_sheet_nesting_edge_process_surface_and_flatpack(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "板式双门边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 18}, "material_group": "主体结构", "machining": {"holes": 4}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 18, "y": 400, "z": 760}, "material_group": "主体结构", "machining": {"holes": 8, "slots": 1}}, + {"part_id": "DOOR-001", "name": "门板", "quantity": 2, "dimensions_mm": {"x": 578, "y": 18, "z": 720}, "material_group": "主体结构"}, + ], + } + ) + project = self.engine._new_project("ctx-particleboard") + project = self.engine._update_project( + project["project_id"], "confirmed", "confirmed", analysis=analysis, + selections={ + "material_family": "engineered_panel", "panel_code": "PARTICLEBOARD", + "main_species": "刨花板", "aux_species": "刨花板", "all_same": True, + "panel_thickness_mode": "bom", "emission_grade": "ENF", + "edge_code": "ABS_2", "edge_scope": "all_edges", + "finish": "三聚氰胺双饰面", "batch_qty": 50, + "shipment_mode": "拆装", "shipping_scenario": "国内专线", + "packaging_profile": "可回收平板包装", + }, + ) + quote = self.engine._calculate_quote(project) + summary = quote["summary"] + self.assertEqual("engineered_panel", summary["material_family"]) + self.assertGreater(summary["material_mid"], 0) + self.assertGreater(summary["edge_material_cost"], 0) + self.assertEqual(0.0, summary["finish_liters"]) + self.assertEqual(0.0, summary["surface_material"]) + self.assertTrue(all(line["purchase_sheet_count"] > 0 for line in quote["material_lines"])) + process_codes = {line["process_code"] for line in quote["process_costing"]["lines"]} + self.assertIn("panel_edge_banding", process_codes) + self.assertNotIn("thicknessing", process_codes) + self.assertNotIn("finishing", process_codes) + self.assertEqual("可回收平板包装", quote["packaging_bom"]["packaging_profile"]) + self.assertIn("不是IKEA供应商认证", quote["packaging_bom"]["public_design_basis"]) + answer = self.engine._completed_answer({"analysis": analysis, "result": {"quote": quote, "files": []}}) + self.assertIn("人造板及封边材料", answer) + self.assertIn("不再增加贴面材料、压贴人工或油漆人工", answer) + + def test_nearby_standard_panel_thickness_does_not_double_sheet_area(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "板式书桌", + "product_type": "桌类", + "overall_dimensions_mm": {"x": 1200, "y": 600, "z": 750}, + "has_doors": False, + "components": [ + { + "part_id": "TOP-001", + "name": "桌面", + "quantity": 1, + "dimensions_mm": {"x": 1200, "y": 600, "z": 20}, + "material_group": "主体结构", + } + ], + } + ) + project = self.engine._new_project("panel-thickness-no-double") + project = self.engine._update_project( + project["project_id"], + "confirmed", + "confirmed", + analysis=analysis, + selections={ + "material_structure": "engineered_panel_all", + "material_family": "engineered_panel", + "panel_code": "MDF", + "main_species": "密度板(MDF)", + "aux_species": "密度板(MDF)", + "all_same": True, + "panel_thickness_mode": "bom", + "emission_grade": "E1", + "panel_purchase_state": "prefinished", + "edge_material": "PVC", + "edge_thickness_mm": 0.5, + "edge_code": "PVC_0_5", + "edge_scope": "exposed_edges", + "finish": "三聚氰胺双饰面", + "batch_qty": 100, + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + }, + ) + quote = self.engine._calculate_quote(project) + line = quote["material_lines"][0] + self.assertEqual(18.0, line["standard_thickness_mm"]) + self.assertEqual(1, line["layer_count"]) + self.assertAlmostEqual(0.72, line["net_area_m2"], places=6) + + def test_packaging_box_style_drives_carton_cost_labor_and_exploded_cad_layers(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "拆装板式柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 800, "y": 400, "z": 12}}, + {"part_id": "SHELF-001", "name": "层板", "quantity": 2, "dimensions_mm": {"x": 1176, "y": 380, "z": 12}}, + ], + } + ) + base = { + "material_structure": "engineered_panel_all", + "material_family": "engineered_panel", + "panel_code": "MDF", + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + } + center = self.engine._packaging_bom(analysis, {**base, "packaging_box_style": "rsc_center_open"}) + lid_base = self.engine._packaging_bom(analysis, {**base, "packaging_box_style": "telescopic_lid_base"}) + self.assertEqual("中开箱", center["packaging_box_style_label"]) + self.assertEqual("FEFCO 0201类", center["packaging_box_style_standard"]) + self.assertEqual("天地盖", lid_base["packaging_box_style_label"]) + self.assertEqual(6.0, center["packaging_minutes_per_package"]) + self.assertEqual(8.0, lid_base["packaging_minutes_per_package"]) + self.assertEqual(1, sum(line["material_code"] == "carton_economy" for line in center["lines"])) + self.assertGreater(center["carton_blank_area_m2"], 0) + roles = {item["node_role"] for item in center["packaging_layers"]} + self.assertTrue({"carton_base", "bottom_cushion", "top_cushion", "carton_top"}.issubset(roles)) + self.assertTrue(any(role.startswith("product_") for role in roles)) + self.assertTrue(any(item["exploded_offset_mm"]["z"] != 0 for item in center["packaging_layers"])) + source = self.engine._source_for_packaging(center) + self.assertIn("packaging_system_exploded", source) + self.assertIn("PKG-01-carton_base", source) + + def test_carton_grade_is_a_separate_interaction_after_box_style(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "90cm拆装餐柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 850}, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 900, "y": 400, "z": 12}}, + ], + } + ) + project = { + "stage": "waiting_packaging_box_style", + "analysis": analysis, + "selections": { + "cost_purpose": "概算", + "material_structure": "engineered_panel_all", + "material_family": "engineered_panel", + "panel_code": "MDF_UNGRADED", + "main_species": "无等级密度板(MDF)", + "panel_thickness_mode": "uniform", + "panel_thickness_mm": 12.0, + "emission_grade": "UNGRADED", + "panel_purchase_state": "prefinished", + "finish": "三聚氰胺双饰面", + "edge_material": "PVC", + "edge_thickness_mm": 0.5, + "edge_code": "PVC_0_5", + "edge_scope": "exposed_edges", + "batch_qty": 50, + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + }, + } + selections = self.engine._parse_selection("使用中开箱包装", project) + self.assertEqual("waiting_carton_grade", self.engine._next_configuration_stage(project, selections)) + answer = self.engine._carton_grade_answer({**project, "selections": selections}) + self.assertIn("90cm推拉餐柜BOM", answer) + self.assertIn("简装级 ¥3.00/㎡", answer) + self.assertIn("经济级 ¥4.00/㎡", answer) + self.assertIn("性能规格", answer) + basic = self.engine._parse_selection("使用简装级纸箱", {**project, "stage": "waiting_carton_grade"}) + self.assertEqual("company_basic", basic["carton_grade"]) + light_domestic = { + **project["selections"], + "shipment_mode": "拆装", + "shipping_scenario": "国内专线", + } + self.assertEqual( + "company_basic", + self.engine._recommended_carton_grade(project["analysis"], light_domestic), + ) + + def test_company_economy_carton_uses_four_yuan_and_two_percent_once(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "90cm拆装餐柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 850}, + "components": [ + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 850, "y": 400, "z": 12}}, + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 900, "y": 400, "z": 12}}, + ], + } + ) + base = { + "material_structure": "engineered_panel_all", + "material_family": "engineered_panel", + "panel_code": "MDF_UNGRADED", + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + "packaging_box_style": "rsc_center_open", + "carton_grade": "company_economy", + } + economy = self.engine._packaging_bom(analysis, base) + carton = next(line for line in economy["lines"] if line["material_code"] == "carton_economy") + self.assertEqual(4.0, carton["unit_price"]) + self.assertEqual(0.02, carton["waste_rate"]) + self.assertAlmostEqual(carton["quantity"] * 4.0 * 1.02, carton["cost"], places=3) + self.assertEqual(1, sum("carton" in line["material_code"] for line in economy["lines"])) + self.assertIn("90cm推拉餐柜BOM", economy["carton_grade_source"]) + self.assertEqual("supplier_confirmation_required", economy["carton_grade_specification_status"]) + self.assertEqual(1.0, economy["carton_cost_multiplier"]) + self.assertTrue(all( + item["material_code"] == "carton_economy" + for item in economy["packaging_layers"] + if item["node_role"].startswith("carton_") or item["node_role"].startswith("lid_wall_") + )) + + standard = self.engine._packaging_bom(analysis, {**base, "carton_grade": "company_standard"}) + self.assertEqual(1.5, standard["carton_cost_multiplier"]) + economy_non_carton = sum(line["cost"] for line in economy["lines"] if "carton" not in line["material_code"]) + standard_non_carton = sum(line["cost"] for line in standard["lines"] if "carton" not in line["material_code"]) + self.assertAlmostEqual(economy_non_carton, standard_non_carton, places=6) + expected_delta = economy["carton_blank_area_m2"] * (6.0 - 4.0) * 1.02 + self.assertAlmostEqual(expected_delta, standard["material_cost"] - economy["material_cost"], places=3) + + def test_company_particleboard_case_has_no_duplicate_surface_cnc_or_packaging_time(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "Solid Oak Sideboard", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "material_group": "主体结构"}, + {"part_id": "BOT-001", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "material_group": "主体结构"}, + {"part_id": "SID-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 720, "z": 400}, "material_group": "主体结构"}, + {"part_id": "DOOR-001", "name": "柜门", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 716}, "material_group": "主体结构"}, + {"part_id": "SHELF-001", "name": "搁板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 380, "z": 20}, "material_group": "主体结构"}, + {"part_id": "LEG-001", "name": "腿", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 40}, "material_group": "主体结构"}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 12, "z": 760}, "material_group": "背板"}, + ], + "structure_decisions": {"back_panel": {"present": True, "thickness_mm": 12, "source": "user_confirmed"}}, + } + ) + project = self.engine._new_project("company-particleboard-regression") + project = self.engine._update_project( + project["project_id"], "confirmed", "confirmed", analysis=analysis, + selections={ + "material_structure": "engineered_panel_all", "material_family": "engineered_panel", + "panel_code": "PARTICLEBOARD", "main_species": "刨花板", "aux_species": "刨花板", + "all_same": True, "panel_thickness_mode": "uniform", "panel_thickness_mm": 18, + "emission_grade": "E0", "edge_code": "PVC_1", "edge_scope": "all_edges", + "finish": "三聚氰胺双饰面", "shipment_mode": "拆装", "shipping_scenario": "电商快递", + "batch_qty": 10, + }, + ) + quote = self.engine._calculate_quote(project) + summary = quote["summary"] + processes = {line["process_code"]: line for line in quote["process_costing"]["lines"]} + self.assertIn("cnc_joinery", processes) + self.assertIn("槽总长", processes["cnc_joinery"]["driver"]) + self.assertNotIn("finishing", processes) + self.assertIn("采购板张折算", processes["material_cutting"]["driver"]) + self.assertEqual(6.0, processes["packaging"]["run_minutes_per_product"]) + self.assertEqual(0.0, summary["finish_labor"]) + self.assertEqual(0.0, summary["surface_material"]) + self.assertFalse(any(line["material_code"] == "honeycomb" for line in quote["packaging_bom"]["lines"])) + back = next(line for line in quote["material_lines"] if line["part_id"] == "BACK-001") + self.assertEqual(18.0, back["quoted_thickness_mm"]) + self.assertEqual("统一板厚已应用到全部板件", back["thickness_rule"]) + self.assertGreaterEqual(summary["total_mid"], 130.0) + self.assertLessEqual(summary["total_mid"], 175.0) + # FEFCO 0201展开面积与CAD层间隔板都进入BOM后,仍需远低于旧版 + # 106元过度包装,同时允许比仅按外表面积估算略高。 + self.assertLessEqual(summary["packaging_cost"], 36.0) + packaging = quote["packaging_bom"] + self.assertLess(packaging["package_dimensions_mm"]["z"], 445.0) + self.assertIn("二维分层", packaging["calculation_method"]) + packed = packaging["packing_layout"] + for index, left in enumerate(packed): + for right in packed[index + 1:]: + if (left["package_index"], left.get("layer_index")) != (right["package_index"], right.get("layer_index")): + continue + left_pos, left_dims = left["position_mm"], left["dimensions_mm"] + right_pos, right_dims = right["position_mm"], right["dimensions_mm"] + separated = ( + left_pos["x"] + left_dims["x"] <= right_pos["x"] + or right_pos["x"] + right_dims["x"] <= left_pos["x"] + or left_pos["y"] + left_dims["y"] <= right_pos["y"] + or right_pos["y"] + right_dims["y"] <= left_pos["y"] + ) + self.assertTrue(separated, (left["node_name"], right["node_name"])) + self.engine._load_ai3d()._validate_source(self.engine._source_for_packaging(packaging)) + + def test_edge_scope_resource_pools_and_unique_ledger_are_exclusive(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "板式边柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 1000, "y": 400, "z": 700}, + "has_doors": False, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1000, "y": 400, "z": 18}, "material_group": "主体结构"}, + {"part_id": "SHELF-001", "name": "层板", "quantity": 2, "dimensions_mm": {"x": 960, "y": 380, "z": 18}, "material_group": "主体结构"}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 960, "y": 9, "z": 660}, "material_group": "背板"}, + ], + } + ) + base = { + "material_structure": "engineered_panel_all", "material_family": "engineered_panel", + "panel_code": "PARTICLEBOARD", "main_species": "刨花板", "aux_species": "刨花板", "all_same": True, + "panel_thickness_mode": "bom", "emission_grade": "E0", "panel_purchase_state": "prefinished", + "edge_material": "PVC", "edge_thickness_mm": 1.0, "edge_code": "PVC_1", + "finish": "三聚氰胺双饰面", "batch_qty": 10, "shipment_mode": "拆装", "shipping_scenario": "电商快递", + } + quotes = {} + for scope, extra in ( + ("all_edges", {}), ("exposed_edges", {}), ("custom", {"custom_edge_length_m": 3.5}), + ): + project = self.engine._new_project(f"edge-{scope}") + selections = {**base, "edge_scope": scope, **extra} + project = self.engine._update_project(project["project_id"], "confirmed", "confirmed", analysis=analysis, selections=selections) + quotes[scope] = self.engine._calculate_quote(project) + self.assertGreater(quotes["all_edges"]["summary"]["edge_length_m"], quotes["exposed_edges"]["summary"]["edge_length_m"]) + self.assertEqual(3.5, quotes["custom"]["summary"]["edge_length_m"]) + quote = quotes["exposed_edges"] + ids = [line["cost_line_id"] for line in quote["cost_ledger"]["lines"]] + self.assertEqual(len(ids), len(set(ids))) + self.assertAlmostEqual(quote["cost_ledger"]["total"], quote["summary"]["total_mid"], places=6) + self.assertEqual(0.0, quote["summary"]["overhead_rate"]) + self.assertIn("不对木材", quote["manufacturing_costing"]["rule"]) + self.assertFalse(quote["process_costing"]["efficiency_applied_again"]) + + def test_carving_cost_requires_positive_driver_and_user_confirmation_when_uncertain(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "雕花边几", + "overall_dimensions_mm": {"x": 600, "y": 400, "z": 500}, + "has_doors": False, + "components": [ + { + "part_id": "TOP-001", "name": "台面", "quantity": 1, + "dimensions_mm": {"x": 600, "y": 400, "z": 20}, "material_group": "主体结构", + "machining": {"carving_area_m2": 0.12, "carving_path_m": 1.8, "evidence": "台面疑似浅浮雕", "confidence": 0.5}, + } + ], + } + ) + self.assertTrue(analysis["process_evidence"]["requires_confirmation"]) + base = { + "material_structure": "solid_wood_all", "material_family": "solid_wood", "main_species": "白橡(北美FAS)", + "aux_species": "白橡(北美FAS)", "all_same": True, "finish": "PU清漆", "batch_qty": 10, + "shipment_mode": "拆装", "shipping_scenario": "电商快递", + } + project = self.engine._new_project("carving-no") + project = self.engine._update_project(project["project_id"], "confirmed", "confirmed", analysis=analysis, selections={**base, "carving_confirmed": False}) + no_quote = self.engine._calculate_quote(project) + self.assertNotIn("carving", {line["process_code"] for line in no_quote["process_costing"]["lines"]}) + project = self.engine._new_project("carving-yes") + project = self.engine._update_project(project["project_id"], "confirmed", "confirmed", analysis=analysis, selections={**base, "carving_confirmed": True}) + yes_quote = self.engine._calculate_quote(project) + self.assertIn("carving", {line["process_code"] for line in yes_quote["process_costing"]["lines"]}) + + def test_single_rejected_image_can_be_replaced_without_losing_accepted_views(self): + project = self.engine._new_project("asset-replace") + images = [ + {"kind": "image_url", "value": f"https://example.test/{role}.png", "filename": f"{name}.png"} + for role, name in (("front", "正面"), ("side", "侧面"), ("open", "开门"), ("dimension", "尺寸图")) + ] + self.engine._store_assets(project["project_id"], images) + assets = self.engine._assets(project["project_id"]) + with self.engine._connect() as conn: + for item in assets: + if item["role"] == "dimension": + conn.execute( + "UPDATE project_assets SET validation_status='rejected',validation_json=? WHERE asset_id=?", + (json.dumps({"issues": ["尺寸数字不可辨识"]}, ensure_ascii=False), item["asset_id"]), + ) + else: + conn.execute("UPDATE project_assets SET validation_status='accepted' WHERE asset_id=?", (item["asset_id"],)) + answer = self.engine._assets_answer(project) + self.assertIn("需单张补传:尺寸数字不可辨识", answer) + self.engine._store_assets( + project["project_id"], + [{"kind": "image_url", "value": "https://example.test/dimension-v2.png", "filename": "尺寸图补传.png"}], + ) + active = self.engine._assets(project["project_id"]) + self.assertEqual(4, len(active)) + self.assertEqual(3, sum(item["validation_status"] == "accepted" for item in active)) + replacement = next(item for item in active if item["validation_status"] == "pending") + self.assertEqual("dimension", replacement["role"]) + history = self.engine._assets(project["project_id"], include_superseded=True) + old = next(item for item in history if item["sha256"] == assets[-1]["sha256"]) + self.assertEqual(replacement["asset_id"], old["replaced_by_asset_id"]) + + def test_multimodal_supplement_never_invalidates_previously_accepted_views(self): + project = self.engine._new_project("asset-validation-checkpoint") + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_url", "value": "https://example.test/front.png", "filename": "正面.png"}, + {"kind": "image_url", "value": "https://example.test/side.png", "filename": "侧面.png"}, + {"kind": "image_url", "value": "https://example.test/open.png", "filename": "开门.png"}, + ], + ) + with self.engine._connect() as conn: + conn.execute("UPDATE project_assets SET validation_status='accepted' WHERE project_id=?", (project["project_id"],)) + self.engine._store_assets( + project["project_id"], + [{"kind": "image_url", "value": "https://example.test/wrong-product.png", "filename": "尺寸图补传.png"}], + ) + assets = self.engine._assets(project["project_id"]) + result_json = { + "same_product": False, + "assets": [ + {"asset_id": item["asset_id"], "detected_role": item["role"], "usable": False, "issues": ["不一致"]} + for item in assets + ], + } + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps({"choices": [{"message": {"content": json.dumps(result_json, ensure_ascii=False)}}]}).encode() + + os.environ["DASHSCOPE_API_KEY"] = "test-key" + with mock.patch.object(self.module.urllib.request, "urlopen", return_value=FakeResponse()): + validation = self.engine._validate_assets_multimodal(project["project_id"], assets) + self.assertFalse(validation["ok"]) + current = self.engine._assets(project["project_id"]) + self.assertEqual(3, sum(item["validation_status"] == "accepted" for item in current)) + rejected = next(item for item in current if item["validation_status"] == "rejected") + self.assertEqual("dimension", rejected["role"]) + self.assertIn("不是同一产品", "".join(rejected["validation"]["issues"])) + + def test_side_outline_is_accepted_when_dimension_sheet_already_has_depth(self): + project = self.engine._new_project("side-outline-with-depth-dimension") + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_url", "value": "https://example.test/front.png", "filename": "正面.png"}, + {"kind": "image_url", "value": "https://example.test/third.png", "filename": "开门.png"}, + {"kind": "image_url", "value": "https://example.test/dimension.png", "filename": "尺寸图.png"}, + ], + ) + with self.engine._connect() as conn: + for item in self.engine._assets(project["project_id"]): + validation = { + "confidence": 0.95, + "dimension_coverage": {"overall_depth": item["role"] == "dimension"}, + } + conn.execute( + "UPDATE project_assets SET validation_status='accepted',validation_json=? WHERE asset_id=?", + (json.dumps(validation, ensure_ascii=False), item["asset_id"]), + ) + self.engine._store_assets( + project["project_id"], + [{"kind": "image_url", "value": "https://example.test/side-v2.png", "filename": "左侧视角图.png"}], + ) + assets = self.engine._assets(project["project_id"]) + pending = next(item for item in assets if item["validation_status"] == "pending") + model_result = { + "same_product": True, + "assets": [ + { + "asset_id": item["asset_id"], + "detected_role": item["role"], + "usable": item["asset_id"] != pending["asset_id"], + "issues": [] if item["asset_id"] != pending["asset_id"] else ["未标注深度或整体尺寸"], + "confidence": 0.90, + "view_evidence": ( + "清晰展示完整侧板和侧面轮廓,但未重复标注深度" + if item["asset_id"] == pending["asset_id"] + else "历史合格图片" + ), + } + for item in assets + ], + } + with mock.patch.object(self.engine, "_request_model_json", return_value=model_result): + validation = self.engine._validate_assets_multimodal(project["project_id"], assets) + self.assertTrue(validation["ok"]) + side = next(item for item in self.engine._assets(project["project_id"]) if item["role"] == "side") + self.assertEqual("accepted", side["validation_status"]) + + def test_front_only_image_is_not_promoted_to_side_by_filename(self): + project = self.engine._new_project("front-only-side-reject") + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_url", "value": "https://example.test/front.png", "filename": "正面.png"}, + {"kind": "image_url", "value": "https://example.test/third.png", "filename": "开门.png"}, + {"kind": "image_url", "value": "https://example.test/dimension.png", "filename": "尺寸图.png"}, + ], + ) + with self.engine._connect() as conn: + for item in self.engine._assets(project["project_id"]): + validation = { + "confidence": 0.95, + "dimension_coverage": {"overall_depth": item["role"] == "dimension"}, + } + conn.execute( + "UPDATE project_assets SET validation_status='accepted',validation_json=? WHERE asset_id=?", + (json.dumps(validation, ensure_ascii=False), item["asset_id"]), + ) + self.engine._store_assets( + project["project_id"], + [{"kind": "image_url", "value": "https://example.test/front-duplicate.png", "filename": "侧视图.png"}], + ) + assets = self.engine._assets(project["project_id"]) + pending = next(item for item in assets if item["validation_status"] == "pending") + model_result = { + "same_product": True, + "assets": [ + { + "asset_id": item["asset_id"], + "detected_role": item["role"], + "usable": item["asset_id"] != pending["asset_id"], + "issues": [] if item["asset_id"] != pending["asset_id"] else ["未展示侧面轮廓"], + "confidence": 0.90, + "view_evidence": ( + "画面是正面投影,未展示侧面轮廓" + if item["asset_id"] == pending["asset_id"] + else "历史合格图片" + ), + } + for item in assets + ], + } + with mock.patch.object(self.engine, "_request_model_json", return_value=model_result): + validation = self.engine._validate_assets_multimodal(project["project_id"], assets) + self.assertFalse(validation["ok"]) + side = next(item for item in self.engine._assets(project["project_id"]) if item["role"] == "side") + self.assertEqual("rejected", side["validation_status"]) + + def test_annotated_side_keeps_side_role_and_detailed_drawing_uses_explicit_envelope(self): + project = self.engine._new_project("annotated-side-and-detailed-drawing") + self.engine._store_assets( + project["project_id"], + [ + {"kind": "image_url", "value": "https://example.test/front.png", "filename": "书架正视图.png"}, + {"kind": "image_url", "value": "https://example.test/side.png", "filename": "书架左侧轮廓复核图.png"}, + {"kind": "image_url", "value": "https://example.test/third.png", "filename": "书架第三视角.png"}, + {"kind": "image_url", "value": "https://example.test/dims.png", "filename": "书架详细尺寸图.png"}, + ], + ) + assets = self.engine._assets(project["project_id"]) + by_declared = {item["role"]: item for item in assets} + model_result = { + "same_product": True, + "assets": [ + {"asset_id": by_declared["front"]["asset_id"], "detected_role": "front", "usable": True, "issues": [], "confidence": 0.95}, + { + "asset_id": by_declared["side"]["asset_id"], "detected_role": "dimension", "usable": True, + "issues": ["整体宽度未标注"], "confidence": 0.90, + "view_evidence": "清晰展示产品高度和前后深度", + "dimension_coverage": {"overall_width": False, "overall_depth": True, "overall_height": True}, + }, + {"asset_id": by_declared["open_or_third"]["asset_id"], "detected_role": "open_or_third", "usable": True, "issues": [], "confidence": 0.90}, + { + "asset_id": by_declared["dimension"]["asset_id"], "detected_role": "dimension", "usable": False, + "issues": ["整体宽度未标注"], "confidence": 0.88, + "dimension_coverage": { + "overall_width": False, "overall_depth": True, "overall_height": True, + "internal_widths": True, "drawer_box": True, + }, + }, + ], + } + with mock.patch.object(self.engine, "_request_model_json", return_value=model_result): + validation = self.engine._validate_assets_multimodal( + project["project_id"], assets, + "同一产品,整体480×300×1600mm,请按尺寸图拆件。", + ) + self.assertTrue(validation["ok"]) + current = self.engine._assets(project["project_id"]) + self.assertEqual(4, sum(item["validation_status"] == "accepted" for item in current)) + self.assertEqual( + {"front", "side", "open_or_third", "dimension"}, + {item["role"] for item in current}, + ) + + def test_metal_reference_tables_are_versioned_and_seed_common_profiles(self): + version, prices, specs = self.engine._active_metal_prices() + process_version, process_prices = self.engine._active_metal_process_prices() + company_version, company_profiles = self.engine._active_company_metal_benchmarks() + self.assertEqual(self.engine.METAL_PRICE_VERSION, version["version_label"]) + self.assertEqual(self.engine.METAL_PROCESS_VERSION, process_version["version_label"]) + self.assertEqual(self.engine.COMPANY_METAL_BENCHMARK_VERSION, company_version["version_label"]) + self.assertEqual({"Q235_BLACK", "Q235_GALV", "SS201", "SS304", "AL6063"}, set(prices)) + self.assertGreaterEqual(len(specs), 55) + self.assertEqual(8, len(company_profiles)) + self.assertAlmostEqual( + 0.708384, + self.engine._metal_profile_kg_m( + "square_tube", width_mm=20, height_mm=20, wall_thickness_mm=1.2 + ), + places=6, + ) + for required in ("tube_cut", "weld_co2", "weld_tig", "powder_coat", "powder_coat_company", "wet_paint"): + self.assertIn(required, process_prices) + + def test_exact_company_metal_profile_replaces_public_kg_price_without_adding_it(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "公司钢管价校准测试", + "overall_dimensions_mm": {"x": 800, "y": 400, "z": 600}, + "has_doors": False, + "components": [ + {"part_id": "LEG-001", "name": "金属支腿", "quantity": 4, "dimensions_mm": {"x": 20, "y": 20, "z": 1000}}, + ], + } + ) + selections = { + "material_structure": "metal_all", + "metal_component_scope": "all", + "metal_material_code": "Q235_BLACK", + "metal_profile_shape": "square_tube", + "metal_profile_width_mm": 20.0, + "metal_profile_height_mm": 20.0, + "metal_profile_diameter_mm": 0.0, + "metal_profile_display": "20×20mm方管", + "metal_wall_thickness_mm": 0.6, + "metal_joining": "bolted", + "metal_joint_count": 0, + "metal_finish": "raw", + "batch_qty": 10, + } + metal = self.engine._metal_costing(analysis, selections) + self.assertTrue(metal["company_benchmark"]["applied"]) + self.assertEqual("company_price_per_meter", metal["material_lines"][0]["pricing_basis"]) + self.assertEqual("m", metal["material_lines"][0]["unit"]) + self.assertAlmostEqual(6.06, metal["material_mid"], places=6) + self.assertNotAlmostEqual( + metal["material_mid"], metal["company_benchmark"]["public_reference_cost_mid"], places=3 + ) + self.assertEqual( + metal["material_mid"], metal["company_benchmark"]["company_cost_mid"] + ) + + def test_mixed_material_interaction_keeps_profile_and_wall_thickness_separate(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "木金属边几", + "product_type": "边几", + "overall_dimensions_mm": {"x": 800, "y": 400, "z": 760}, + "has_doors": False, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 800, "y": 400, "z": 18}}, + {"part_id": "LEG-001", "name": "金属支腿", "quantity": 4, "dimensions_mm": {"x": 25, "y": 25, "z": 742}}, + ], + } + ) + project = self.engine._new_project("mixed-metal-interaction") + project = self.engine._update_project( + project["project_id"], "waiting_material_structure", "waiting", analysis=analysis, + selections={"cost_purpose": "概算"}, + ) + selected = self.engine._parse_selection("选择板材加金属结构", project) + self.assertEqual("engineered_panel_metal", selected["material_structure"]) + project = {**project, "selections": selected} + selected = self.engine._parse_selection("全部木质部件使用刨花板", project) + self.assertEqual("engineered_panel_metal", selected["material_structure"]) + selected = self.engine._parse_selection("金属型材使用25×25mm方管", {**project, "selections": selected}) + self.assertEqual("square_tube", selected["metal_profile_shape"]) + self.assertNotIn("metal_wall_thickness_mm", selected) + selected = self.engine._parse_selection("金属管壁厚1.2mm", {**project, "selections": selected}) + self.assertEqual(1.2, selected["metal_wall_thickness_mm"]) + + def test_edge_material_and_thickness_are_independent_decisions(self): + project = self.engine._new_project("edge-material-thickness") + project = {**project, "selections": {"panel_thickness_mode": "bom"}} + selected = self.engine._parse_selection("使用PVC封边", project) + self.assertEqual("PVC", selected["edge_material"]) + self.assertNotIn("edge_thickness_mm", selected) + self.assertNotIn("edge_code", selected) + selected = self.engine._parse_selection( + "封边厚度1mm", {**project, "selections": selected} + ) + self.assertEqual("PVC", selected["edge_material"]) + self.assertEqual(1.0, selected["edge_thickness_mm"]) + self.assertEqual("PVC_1", selected["edge_code"]) + self.assertNotIn("edge_scope", selected) + self.assertEqual("bom", selected["panel_thickness_mode"]) + + custom = self.engine._parse_selection( + "自定义封边总长12米", {**project, "selections": selected} + ) + self.assertEqual("custom", custom["edge_scope"]) + self.assertEqual(12.0, custom["custom_edge_length_m"]) + + changed = self.engine._parse_selection( + "使用ABS封边", {**project, "selections": selected} + ) + self.assertEqual("ABS", changed["edge_material"]) + self.assertNotIn("edge_thickness_mm", changed) + self.assertNotIn("edge_code", changed) + + def test_custom_metal_leg_scope_matches_ai_bom_leg_synonym(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "板金边柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": False, + "components": [ + {"part_id": "LEG-001", "name": "腿 (4)", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 400}}, + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 18}}, + ], + } + ) + parts = self.engine._metal_components( + analysis, + { + "material_structure": "engineered_panel_metal", + "metal_component_scope": "custom", + "metal_component_keywords": ["支腿"], + }, + ) + self.assertEqual(["LEG-001"], [item["part_id"] for item in parts]) + + def test_custom_metal_crossbar_scope_matches_support_rod_synonym(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "板金书桌", + "overall_dimensions_mm": {"x": 1200, "y": 600, "z": 750}, + "has_doors": False, + "components": [ + {"part_id": "BAR-001", "name": "横向支撑杆", "quantity": 2, "dimensions_mm": {"x": 1100, "y": 50, "z": 50}}, + {"part_id": "TOP-001", "name": "桌面板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 600, "z": 18}}, + ], + } + ) + parts = self.engine._metal_components( + analysis, + { + "material_structure": "engineered_panel_metal", + "metal_component_scope": "custom", + "metal_component_keywords": ["金属横撑"], + }, + ) + self.assertEqual(["BAR-001"], [item["part_id"] for item in parts]) + + def test_mixed_panel_metal_quote_uses_physical_drivers_without_duplicate_costs(self): + self.engine.history_enabled = False + analysis = self.engine._normalize_analysis( + { + "product_name": "板木金属书桌", + "product_type": "书桌", + "overall_dimensions_mm": {"x": 1200, "y": 600, "z": 750}, + "has_doors": False, + "components": [ + {"part_id": "TOP-001", "name": "桌面板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 600, "z": 18}}, + {"part_id": "LEG-001", "name": "金属支腿", "quantity": 4, "dimensions_mm": {"x": 25, "y": 25, "z": 732}}, + {"part_id": "BAR-001", "name": "金属横撑", "quantity": 2, "dimensions_mm": {"x": 1100, "y": 25, "z": 25}}, + ], + } + ) + project = self.engine._new_project("mixed-metal-quote") + project = self.engine._update_project( + project["project_id"], "confirmed", "confirmed", analysis=analysis, + selections={ + "cost_purpose": "概算", + "material_structure": "engineered_panel_metal", + "material_family": "engineered_panel", + "panel_code": "PARTICLEBOARD", + "main_species": "刨花板", + "aux_species": "刨花板", + "all_same": True, + "panel_thickness_mode": "bom", + "emission_grade": "E0", + "panel_purchase_state": "prefinished", + "edge_material": "PVC", + "edge_thickness_mm": 1.0, + "edge_code": "PVC_1", + "edge_scope": "exposed_edges", + "finish": "三聚氰胺双饰面", + "metal_component_scope": "custom", + "metal_component_keywords": ["金属支腿", "金属横撑"], + "metal_material_code": "Q235_BLACK", + "metal_material_name": "Q235碳钢黑管", + "metal_profile_shape": "square_tube", + "metal_profile_width_mm": 25.0, + "metal_profile_height_mm": 25.0, + "metal_profile_diameter_mm": 0.0, + "metal_profile_display": "25×25mm方管", + "metal_wall_thickness_mm": 1.2, + "metal_joining": "weld_co2", + "metal_joint_count": 8, + "metal_finish": "phosphating_powder", + "batch_qty": 10, + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + }, + ) + quote = self.engine._calculate_quote(project) + metal = quote["metal_costing"] + self.assertEqual(2, len(metal["material_lines"])) + self.assertGreater(metal["purchase_weight_kg"], 0) + self.assertGreater(metal["coating_area_m2"], 0) + self.assertGreater(metal["weld_length_m"], 0) + process_codes = {line["process_code"] for line in metal["process_lines"]} + self.assertEqual({"tube_cut", "weld_co2", "weld_grind", "powder_coat_company"}, process_codes) + powder = next(line for line in metal["process_lines"] if line["process_code"] == "powder_coat_company") + self.assertIn("已含除油磷化", powder["driver"]) + self.assertEqual(0.0, powder["setup_cost_batch"]) + self.assertNotIn("bend", process_codes) + self.assertNotIn("laser_cut", process_codes) + ids = [line["cost_line_id"] for line in quote["cost_ledger"]["lines"]] + self.assertEqual(len(ids), len(set(ids))) + self.assertAlmostEqual(quote["summary"]["total_mid"], quote["cost_ledger"]["total"], places=6) + self.assertEqual("engineered_panel_metal", quote["summary"]["material_family"]) + + def _post_quote_fixture(self, context_key="post-quote"): + project = self.engine._new_project(context_key) + analysis = { + "product_name": "测试边柜", + "product_type": "边柜", + "components": [], + } + quote = { + "summary": { + "total_mid": 100.0, + "total_low": 90.0, + "total_high": 110.0, + "total_packaging_volume_m3": 0.5, + }, + "material_lines": [ + { + "part_id": "TOP-001", + "name": "顶板", + "quantity": 1, + "dimensions_mm": {"x": 1000, "y": 400, "z": 18}, + "species": "刨花板", + "cost_mid": 20.0, + } + ], + } + result = {"quote": quote, "files": [{"filename": "bom.xlsx", "url": "https://example.test/bom.xlsx"}]} + selections = { + "post_quote_step": "wholesale_need", + "final_confirmed": True, + "material_structure": "engineered_panel_all", + } + project = self.engine._update_project( + project["project_id"], "waiting_confirmation", "等待1688设置", + analysis=analysis, selections=selections, result=result, + ) + now = self.engine._now() + with self.engine._connect() as conn: + conn.execute( + """ + INSERT INTO project_assets( + project_id,role,filename,kind,value,sha256,validation_status,validation_json,created_at + ) VALUES(?,?,?,?,?,?,?,?,?) + """, + ( + project["project_id"], "front", "正面.png", "image_url", "https://example.test/front.png", + "fixture-sha", "accepted", '{"confidence":0.99}', now, + ), + ) + return self.engine.get_project(project["project_id"]) + + def test_1688_special_operation_prices_are_versioned(self): + version, prices = self.engine._active_special_operation_prices() + self.assertEqual(self.engine.SPECIAL_OPERATION_PRICE_VERSION, version["version_label"]) + self.assertEqual(10, len(prices)) + self.assertEqual(6.0, prices["woven_bag_rope_remove_barcode"]["price_including_material"]) + self.assertEqual("m3", prices["container_loading_unloading"]["driver_unit"]) + self.module.SolidWoodCostEngine() + with self.engine._connect() as conn: + count = conn.execute( + "SELECT COUNT(*) FROM special_operation_price_versions WHERE version_label=?", + (self.engine.SPECIAL_OPERATION_PRICE_VERSION,), + ).fetchone()[0] + self.assertEqual(1, count) + + def test_1688_post_quote_flow_calculates_formula_and_archives_idempotently(self): + project = self._post_quote_fixture("post-quote-full") + result = self.engine._handle_post_quote( + project, "是,需要报1688批发价格", conversation_id="conv-1", user_id="operator-1" + ) + self.assertEqual("waiting", result["status"]) + project = self.engine.get_project(project["project_id"]) + self.assertEqual("special_need", project["selections"]["post_quote_step"]) + self.engine._handle_post_quote( + project, "有特殊操作需求", conversation_id="conv-1", user_id="operator-1" + ) + project = self.engine.get_project(project["project_id"]) + self.engine._handle_post_quote( + project, "添加特殊操作:套编织袋", conversation_id="conv-1", user_id="operator-1" + ) + project = self.engine.get_project(project["project_id"]) + # 重复点选同一项不得重复加价。 + self.engine._handle_post_quote( + project, "添加特殊操作:套编织袋", conversation_id="conv-1", user_id="operator-1" + ) + project = self.engine.get_project(project["project_id"]) + self.assertEqual(["woven_bag"], project["selections"]["special_operation_codes"]) + self.engine._handle_post_quote( + project, "特殊操作选择完成", conversation_id="conv-1", user_id="operator-1" + ) + project = self.engine.get_project(project["project_id"]) + self.engine._handle_post_quote( + project, "套编织袋按含耗材计价", conversation_id="conv-1", user_id="operator-1" + ) + project = self.engine.get_project(project["project_id"]) + self.engine._handle_post_quote( + project, "1688批发报价增加5%税点", conversation_id="conv-1", user_id="operator-1" + ) + project = self.engine.get_project(project["project_id"]) + completed = self.engine._handle_post_quote( + project, "目标利润空间20%", conversation_id="conv-1", user_id="operator-1" + ) + self.assertEqual("completed", completed["status"]) + project = self.engine.get_project(project["project_id"]) + wholesale = project["result"]["quote"]["wholesale_quote"] + factory = project["result"]["quote"]["factory_quote"] + self.assertFalse(factory["pure_solid_wood"]) + self.assertEqual(0.08, factory["factory_profit_rate"]) + self.assertEqual(8.0, factory["factory_profit_amount"]) + self.assertEqual(7.56, factory["factory_output_tax_amount"]) + self.assertEqual(115.56, factory["bom_factory_quote"]) + self.assertEqual(5.0, wholesale["special_operation_total"]) + self.assertEqual(6.03, wholesale["invoice_tax_amount"]) + self.assertEqual(158.24, wholesale["final_quote"]) + self.assertIn("最终1688批发价", completed["answer"]) + self.assertTrue(project["result"].get("archive", {}).get("archive_id", "").startswith("archive_wood_")) + # 同一项目重新归档是覆盖更新,不新增第二份档案或明细。 + self.engine._finalize_post_quote( + project, + selections=project["selections"], + conversation_id="conv-1", + user_id="operator-1", + ) + with self.engine._connect() as conn: + archive = conn.execute( + "SELECT * FROM cost_quote_archives WHERE project_id=?", (project["project_id"],) + ).fetchone() + self.assertEqual("operator-1", archive["operator_id"]) + self.assertEqual(0.08, archive["factory_profit_rate"]) + self.assertEqual(7.56, archive["factory_output_tax_amount"]) + self.assertEqual(115.56, archive["bom_factory_quote"]) + self.assertEqual(1, conn.execute("SELECT COUNT(*) FROM cost_quote_archives WHERE project_id=?", (project["project_id"],)).fetchone()[0]) + self.assertEqual(1, conn.execute("SELECT COUNT(*) FROM cost_quote_archive_assets WHERE archive_id=?", (archive["archive_id"],)).fetchone()[0]) + self.assertEqual(1, conn.execute("SELECT COUNT(*) FROM cost_quote_archive_bom_lines WHERE archive_id=?", (archive["archive_id"],)).fetchone()[0]) + self.assertEqual(1, conn.execute("SELECT COUNT(*) FROM cost_quote_archive_special_operations WHERE archive_id=?", (archive["archive_id"],)).fetchone()[0]) + + def test_completed_bom_only_quote_can_reopen_1688_without_new_project(self): + conversation_id = "completed-reopen-conversation" + user_id = "completed-reopen-user" + session_key = "completed-reopen-session" + project = self._post_quote_fixture( + self.engine.context_key(conversation_id, user_id, session_key) + ) + completed = self.engine._handle_post_quote( + project, + "否,只要BOM出厂报价", + conversation_id=conversation_id, + user_id=user_id, + ) + self.assertEqual("completed", completed["status"]) + completed_project_id = project["project_id"] + + reopened = self.engine.run( + "是,需要报1688批发价格", + conversation_id=conversation_id, + user_id=user_id, + session_key=session_key, + ) + self.assertEqual("waiting", reopened["status"]) + self.assertIn("是否有特殊操作需求", reopened["answer"]) + reopened_project = self.engine.get_project(completed_project_id) + self.assertEqual(completed_project_id, reopened_project["project_id"]) + self.assertEqual("waiting_confirmation", reopened_project["stage"]) + self.assertEqual("special_need", reopened_project["selections"]["post_quote_step"]) + + def test_completed_quote_finish_edit_clears_post_quote_state_and_does_not_loop_to_margin(self): + conversation_id = "completed-finish-edit-conversation" + user_id = "completed-finish-edit-user" + session_key = "completed-finish-edit-session" + project = self._post_quote_fixture( + self.engine.context_key(conversation_id, user_id, session_key) + ) + completed = self.engine._handle_post_quote( + project, + "否,只要BOM出厂报价", + conversation_id=conversation_id, + user_id=user_id, + ) + self.assertEqual("completed", completed["status"]) + saved = self.engine.get_project(project["project_id"]) + selections = dict(saved["selections"]) + selections.update( + { + "cost_purpose": "概算", + "material_structure": "solid_wood_all", + "material_family": "solid_wood", + "main_species": "白橡(北美FAS)", + "aux_species": "白橡(北美FAS)", + "all_same": True, + "finish": "PU清漆", + "finish_color": "透明原木色", + "finish_texture": "开放效果", + "finish_gloss": "哑光", + "shipment_mode": "拆装", + "packaging_box_style": "rsc_center_open", + "carton_grade": "company_economy", + "batch_qty": self.engine.DEFAULT_BATCH_QTY, + "final_confirmed": True, + } + ) + self.engine._update_project( + saved["project_id"], "completed", "报价已完成", selections=selections + ) + + editing = self.engine.run( + "修改这个项目的油漆工艺并重新计算", + conversation_id=conversation_id, + user_id=user_id, + session_key=session_key, + ) + self.assertEqual("waiting", editing["status"]) + self.assertIn("选择油漆工艺", editing["answer"]) + editing_project = self.engine.get_project(saved["project_id"]) + self.assertEqual("waiting_finish", editing_project["stage"]) + self.assertFalse(editing_project["result"]) + for key in ( + "post_quote_step", "wholesale_1688_enabled", "special_operation_codes", + "special_operation_material_basis", "invoice_tax_enabled", "profit_margin", + ): + self.assertNotIn(key, editing_project["selections"]) + + finish = self.engine.run( + "使用PU清漆,下一步", + conversation_id=conversation_id, + user_id=user_id, + session_key=session_key, + ) + self.assertIn("确认油漆外观", finish["answer"]) + details = self.engine.run( + "透明原木色,开放效果,哑光", + conversation_id=conversation_id, + user_id=user_id, + session_key=session_key, + ) + self.assertIn("最终条件确认", details["answer"]) + self.assertNotIn("输入目标利润空间", details["answer"]) + + def test_completed_post_quote_state_never_falls_through_to_profit_prompt(self): + project = self._post_quote_fixture("completed-post-quote-defensive") + selections = dict(project["selections"]) + selections["post_quote_step"] = "completed" + project = self.engine._update_project( + project["project_id"], "waiting_confirmation", "历史阶段残留", selections=selections + ) + answer = self.engine._post_quote_answer(project) + self.assertNotIn("输入目标利润空间", answer) + handled = self.engine._handle_post_quote( + project, "目标利润空间22%", conversation_id="conv", user_id="user" + ) + self.assertEqual("completed", handled["status"]) + + def test_bom_only_path_skips_tax_margin_and_still_archives(self): + project = self._post_quote_fixture("post-quote-bom-only") + completed = self.engine._handle_post_quote( + project, "否,只要BOM出厂报价", conversation_id="conv-2", user_id="operator-2" + ) + self.assertEqual("completed", completed["status"]) + saved = self.engine.get_project(project["project_id"]) + wholesale = saved["result"]["quote"]["wholesale_quote"] + factory = saved["result"]["quote"]["factory_quote"] + self.assertFalse(wholesale["enabled"]) + self.assertEqual(115.56, wholesale["final_quote"]) + self.assertEqual(115.56, factory["bom_factory_quote"]) + self.assertEqual(0.0, wholesale["invoice_tax_amount"]) + self.assertEqual(0.0, wholesale["profit_margin"]) + + def test_pure_solid_wood_factory_quote_uses_ten_percent_profit_then_output_tax(self): + factory = self.engine._factory_quote_layer(100.0, {"material_structure": "solid_wood_all"}) + self.assertTrue(factory["pure_solid_wood"]) + self.assertEqual(0.10, factory["factory_profit_rate"]) + self.assertEqual(10.0, factory["factory_profit_amount"]) + self.assertEqual(110.0, factory["before_output_tax"]) + self.assertEqual(7.70, factory["factory_output_tax_amount"]) + self.assertEqual(117.70, factory["bom_factory_quote"]) + + def test_12mm_panel_production_joinery_drives_cad_hardware_and_cost_once(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "单抽移门柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 850}, + "has_doors": True, + "door_mechanism": "sliding", + "drawer_count": 1, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 900, "y": 400, "z": 12}}, + {"part_id": "BOT-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 876, "y": 400, "z": 12}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 826}}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 876, "y": 12, "z": 826}}, + {"part_id": "DOOR-001", "name": "移门", "quantity": 2, "dimensions_mm": {"x": 450, "y": 12, "z": 700}}, + {"part_id": "DRAWER-F-001", "name": "第1个抽屉面板", "quantity": 1, "dimensions_mm": {"x": 400, "y": 12, "z": 140}}, + {"part_id": "DRAWER-L-001", "name": "第1个抽屉左帮板(左侧板)", "quantity": 1, "dimensions_mm": {"x": 350, "y": 100, "z": 12}}, + {"part_id": "DRAWER-R-001", "name": "第1个抽屉右帮板(右侧板)", "quantity": 1, "dimensions_mm": {"x": 350, "y": 100, "z": 12}}, + {"part_id": "DRAWER-BACK-001", "name": "第1个抽屉后帮板(后侧板)", "quantity": 1, "dimensions_mm": {"x": 376, "y": 100, "z": 12}}, + {"part_id": "DRAWER-BOTTOM-001", "name": "第1个抽屉底板", "quantity": 1, "dimensions_mm": {"x": 376, "y": 350, "z": 12}}, + ], + } + ) + selections = { + "material_structure": "engineered_panel_all", "material_family": "engineered_panel", + "panel_code": "MDF_UNGRADED", "main_species": "无等级密度板(MDF)", + "aux_species": "无等级密度板(MDF)", "all_same": True, + "panel_thickness_mode": "uniform", "panel_thickness_mm": 12.0, + "emission_grade": "UNGRADED", "panel_purchase_state": "prefinished", + "edge_code": "PVC_0_5", "edge_scope": "exposed_edges", "finish": "三聚氰胺双饰面", + "hardware_grade": "basic", "batch_qty": 50, "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + } + adjusted = self.engine._analysis_for_selected_thickness(analysis, selections) + joined = self.engine._apply_production_joinery(adjusted, selections) + self.assertEqual(joined, self.engine._apply_production_joinery(joined, selections)) + plan = joined["production_joinery"] + self.assertEqual("柜体确认螺钉5×30mm+Φ8×30mm定位木榫", plan["connection_system"]) + self.assertEqual(4, plan["back_panel_groove"]["count"]) + self.assertGreaterEqual(plan["drawer_bottom_groove"]["count"], 4) + self.assertEqual(4, plan["totals"]["carcass_connections"]) + self.assertEqual(8, plan["totals"]["drawer_connections"]) + hardware_by_code = {line["hardware_code"]: line for line in plan["hardware_lines"]} + self.assertEqual(8, hardware_by_code["confirmat_5x30"]["quantity"]) + self.assertEqual(8, hardware_by_code["wood_dowel_8x30"]["quantity"]) + self.assertEqual(8, hardware_by_code["drawer_screw_3_5x30"]["quantity"]) + source = self.engine._source_for_components(joined, layout="assembled") + self.engine._load_ai3d()._validate_source(source) + self.assertIn("Cylinder(", source) + self.assertIn("_groove_", source) + self.assertIn(" = part_", source) + + project = self.engine._new_project("production-joinery-quote") + project = self.engine._update_project( + project["project_id"], "confirmed", "confirmed", analysis=analysis, selections=selections + ) + quote = self.engine._calculate_quote(project) + hardware_codes = [line.get("hardware_code") for line in quote["hardware_bom"]["lines"]] + self.assertIn("confirmat_5x30", hardware_codes) + self.assertNotIn("connector", hardware_codes) + cnc = next(line for line in quote["process_costing"]["lines"] if line["process_code"] == "cnc_joinery") + self.assertIn("槽总长", cnc["driver"]) + self.assertGreater(cnc["cost_per_product"], 0) + job_dir = pathlib.Path(self.temp.name) / "joinery-export" + job_dir.mkdir() + files = self.engine._write_quote_files(job_dir, project["project_id"], quote) + workbook = load_workbook(files["bom_xlsx_path"], data_only=True) + self.assertGreater(workbook["连接孔槽明细"].max_row, 5) + self.assertEqual("单件孔数", workbook["部件BOM"]["U1"].value) + + def test_shelf_support_holes_hardware_and_cost_driver_are_generated_once(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "单层板边柜", "product_type": "柜类", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "BOT-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 400, "z": 720}}, + {"part_id": "SHELF-001", "name": "层板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 380, "z": 20}}, + {"part_id": "DOOR-001", "name": "柜门", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 716}}, + {"part_id": "LEG-001", "name": "支腿", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 40}}, + ], + } + ) + selections = { + "material_structure": "solid_wood_all", "main_species": "白橡(北美FAS)", + "finish": "PU清漆", "shipment_mode": "拆装", "batch_qty": 50, + } + joined = self.engine._apply_production_joinery(analysis, selections) + plan = joined["production_joinery"] + self.assertEqual({ + "enabled": True, "shelf_count": 1, "pin_quantity": 4, + "hole_diameter_mm": 5.0, "template_id": "DLX-SHELF-PIN-5MM-V1", + }, plan["shelf_support"]) + pin_line = next(line for line in plan["hardware_lines"] if line["hardware_code"] == "shelf_pin_5mm") + self.assertEqual(4, pin_line["quantity"]) + side = next(item for item in joined["components"] if item["part_id"] == "SIDE-001") + shelf_holes = [ + hole for hole in side["manufacturing_features"]["holes"] + if hole["group"] == "shelf_support" + ] + self.assertEqual(2, len(shelf_holes)) + self.assertTrue(all(hole["hardware_template_id"] == "DLX-SHELF-PIN-5MM-V1" for hole in shelf_holes)) + self.assertEqual(joined, self.engine._apply_production_joinery(joined, selections)) + + def test_production_geometry_gate_repairs_back_fit_leg_connection_and_carcass_axes(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "白橡边柜", "product_type": "柜类", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, "door_mechanism": "hinged", + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "BOT-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "SID-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 400, "z": 720}}, + {"part_id": "SHF-001", "name": "层板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 380, "z": 20}}, + {"part_id": "DOO-001", "name": "柜门", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 716}}, + {"part_id": "LEG-001", "name": "柜腿", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 40}}, + {"part_id": "BACK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 720}}, + ], + } + ) + selections = { + "material_structure": "solid_wood_all", "main_species": "白橡(北美FAS)", + "aux_species": "白橡(北美FAS)", "finish": "PU清漆", + "finish_color": "透明原木色", "finish_texture": "开放", "finish_gloss": "哑光", + "shipment_mode": "拆装", "shipping_scenario": "电商快递", "batch_qty": 50, + "packaging_box_style": "rsc_center_open", "carton_grade": "company_economy", + "hardware_grade": "basic", + } + blocked = self.engine._apply_production_joinery(analysis, selections) + codes = {item.get("code") for item in blocked["production_joinery"]["validation_issues"]} + self.assertIn("back_panel_groove_fit_mismatch", codes) + self.assertIn("leg_connection_unspecified", codes) + deterministic_codes = {item.get("code") for item in self.engine._deterministic_cad_review_issues(blocked)} + self.assertIn("back_panel_groove_fit_mismatch", deterministic_codes) + self.assertIn("leg_connection_unspecified", deterministic_codes) + + corrected = json.loads(json.dumps(blocked, ensure_ascii=False)) + self.assertTrue( + self.engine._apply_user_connection_decisions( + corrected, + "背板采用四边6mm嵌槽并按槽深修正尺寸;柜腿采用M8吊脚螺栓加柜底预埋螺母连接", + ) + ) + corrected.pop("production_joinery", None) + joined = self.engine._apply_production_joinery(corrected, selections) + plan = joined["production_joinery"] + self.assertEqual("valid", plan["back_panel_fit"]["status"]) + self.assertTrue(plan["leg_connection"]["enabled"]) + self.assertFalse(plan["validation_issues"]) + back = next(item for item in joined["components"] if item["part_id"] == "BACK-001") + self.assertEqual({"x": 1172.0, "y": 9.0, "z": 732.0}, back["dimensions_mm"]) + top = next(item for item in joined["components"] if item["part_id"] == "TOP-001") + side = next(item for item in joined["components"] if item["part_id"] == "SID-001") + self.assertTrue(all(hole["axis"] == "z" for hole in top["manufacturing_features"]["holes"])) + self.assertTrue(all( + hole["axis"] == "z" + for hole in side["manufacturing_features"]["holes"] + if hole["group"] == "carcass_connection" + )) + leg_codes = {line["hardware_code"] for line in plan["hardware_lines"]} + self.assertIn("leg_hanger_bolt_m8", leg_codes) + self.assertIn("threaded_insert_m8", leg_codes) + instances = [item for item in self.engine._component_instances(joined) if item["part_id"] == "SID-001"] + first_groove = instances[0]["manufacturing_features"]["grooves"][0] + second_groove = instances[1]["manufacturing_features"]["grooves"][0] + self.assertEqual(20.0, first_groove["center_mm"]["x"] + second_groove["center_mm"]["x"]) + self.assertTrue(instances[1]["mirrored_from_part_drawing"]) + + joined["assumptions"] = [ + "层板与侧板、顶板之间通过胶合或固定件连接,具体方式未明确。", + "所有板材边缘均经过打磨处理,无毛刺。", + "五金件安装孔位精确,确保门板开合顺畅。", + "产品表面涂装均匀,颜色一致。", + "尺寸图未直接标注木材含水率。", + ] + + project = self.engine._new_project("production-geometry-v37") + project = self.engine._update_project( + project["project_id"], "confirmed", "confirmed", analysis=joined, selections=selections + ) + quote = self.engine._calculate_quote(project) + assembly = next(line for line in quote["process_costing"]["lines"] if line["process_code"] == "assembly") + self.assertLessEqual(assembly["run_minutes_per_product"], 10.0) + self.assertIn("不计完整整装", assembly["driver"]) + assumptions_text = "\n".join(quote["assumptions"]) + self.assertNotIn("层板与侧板、顶板之间通过胶合或固定件连接", assumptions_text) + self.assertNotIn("所有板材边缘均经过打磨处理", assumptions_text) + self.assertNotIn("五金件安装孔位精确", assumptions_text) + self.assertNotIn("产品表面涂装均匀", assumptions_text) + self.assertNotIn("尺寸图未直接标注木材含水率", assumptions_text) + self.assertFalse(any(item in quote["assumptions"] for item in joined["assumptions"])) + + def test_low_confidence_prompts_are_product_neutral_and_hide_internal_errors(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "边柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "components": [ + { + "part_id": "LEG-001", + "name": "柜腿", + "quantity": 4, + "dimensions_mm": {"x": 40, "y": 40, "z": 40}, + "dimension_confidence": 0.55, + "dimension_evidence": {"source": "CAD反推"}, + } + ], + } + ) + low_items = [ + {"id": "leg_height", "label": "柜腿高度", "value": 40, "confidence": 0.55, "reason": "未直接标注"} + ] + analysis["structure_questions"] = [ + { + "id": "cad_estimation_consent", + "label": "低置信尺寸", + "reason": "当前素材不能默认当成精确数值", + "prompt": "请补充或同意按CAD预估", + "items": low_items, + } + ] + analysis["uncertainties"] = [ + "尺寸图的部分标注未能稳定转录,已保留多视角识别结果;低置信尺寸会在下一步单独请用户确认" + ] + answer = self.engine._structure_answer({"analysis": analysis}) + self.assertIn("我填写关键尺寸:", answer) + self.assertNotIn("花城", answer) + self.assertNotIn("抽屉箱体内尺寸", answer) + self.assertNotIn("模型连续3次未返回", answer) + self.assertNotIn("Expecting", answer) + + def test_deterministic_cad_blockers_offer_repair_not_blind_approval(self): + project = { + "analysis": { + "product_name": "边柜", + "cad_review": { + "issues": [ + { + "code": "back_panel_groove_fit_mismatch", + "severity": "blocking", + "source": "deterministic_joinery_gate", + "component": "BACK-001", + "description": "背板与四边嵌槽尺寸不匹配", + "suggested_question": "请修正背板尺寸", + "confidence": 1.0, + }, + { + "code": "leg_connection_unspecified", + "severity": "blocking", + "source": "deterministic_joinery_gate", + "component": "LEG-001", + "description": "柜腿连接未指定", + "suggested_question": "请确认连接件", + "confidence": 1.0, + }, + ] + }, + } + } + answer = self.engine._cad_review_answer(project) + self.assertIn("采用推荐修正并重建", answer) + self.assertIn("背板确认采用四边6mm嵌槽", answer) + self.assertIn("柜腿确认采用M8吊脚螺栓", answer) + self.assertNotIn("确认当前CAD继续", answer) + self.assertNotIn("抽屉箱体内尺寸", answer) + + project["analysis"]["cad_review"]["issues"] = [ + { + "code": "cad_review_low_confidence", + "severity": "warning", + "source": "review_gate", + "component": "整体CAD", + "description": "复核置信度不足", + "suggested_question": "请确认", + "confidence": 0.65, + } + ] + warning_answer = self.engine._cad_review_answer(project) + self.assertIn("确认当前CAD继续", warning_answer) + + def test_engineered_panel_rejects_traditional_mortise_and_keeps_audit(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "板式测试柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 800, "y": 400, "z": 800}, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 800, "y": 400, "z": 12}, "machining": {"mortise_tenon": 2}}, + {"part_id": "BOT-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 776, "y": 400, "z": 12}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 776}}, + ], + } + ) + selections = { + "material_structure": "engineered_panel_all", + "panel_code": "MDF_UNGRADED", + "main_species": "无等级密度板(MDF)", + "panel_thickness_mode": "uniform", + "panel_thickness_mm": 12.0, + "emission_grade": "UNGRADED", + "panel_purchase_state": "prefinished", + "finish": "三聚氰胺双饰面", + "edge_code": "PVC_0_5", + "edge_scope": "exposed_edges", + "hardware_grade": "basic", + "batch_qty": 50, + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + } + joined = self.engine._apply_production_joinery(analysis, selections) + top = next(item for item in joined["components"] if item["part_id"] == "TOP-001") + self.assertEqual(0, top["machining"]["mortise_tenon"]) + rejected = joined["production_joinery"]["rejected_incompatible_features"] + self.assertEqual(2, rejected[0]["rejected_quantity"]) + self.assertIn("人造板", rejected[0]["reason"]) + self.assertEqual("dalongxia.production-joinery.v2", joined["production_joinery"]["schema"]) + first_hole = next( + feature + for item in joined["components"] + for feature in (item.get("manufacturing_features") or {}).get("holes") or [] + ) + self.assertEqual("valid", first_hole["validation_status"]) + self.assertTrue(first_hole["hardware_template_id"].startswith("DLX-")) + self.assertIn("position_2d_mm", first_hole) + + def test_production_drawing_bundle_contains_pdf_dxf_manifest_and_release_gate(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "无门板式柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 800, "y": 400, "z": 800}, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 800, "y": 400, "z": 12}, "confidence": 0.95}, + {"part_id": "BOT-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 776, "y": 400, "z": 12}, "confidence": 0.95}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 776}, "confidence": 0.95}, + ], + } + ) + selections = { + "material_structure": "engineered_panel_all", "panel_code": "MDF_UNGRADED", + "main_species": "无等级密度板(MDF)", "panel_thickness_mode": "uniform", + "panel_thickness_mm": 12.0, "emission_grade": "UNGRADED", "panel_purchase_state": "prefinished", + "finish": "三聚氰胺双饰面", "edge_code": "PVC_0_5", "edge_scope": "exposed_edges", + "hardware_grade": "basic", "batch_qty": 50, "shipment_mode": "拆装", "shipping_scenario": "电商快递", + } + joined = self.engine._apply_production_joinery(analysis, selections) + project = self.engine._new_project("production-drawing-bundle") + project = self.engine._update_project( + project["project_id"], "confirmed", "confirmed", analysis=joined, selections=selections + ) + quote = self.engine._calculate_quote(project) + job_dir = pathlib.Path(self.temp.name) / "production-drawing-export" + job_dir.mkdir() + files = self.engine._write_manufacturing_drawings(job_dir, project["project_id"], joined, quote) + manifest = files["production_drawing_manifest"] + self.assertEqual("drawing_draft", manifest["release_status"]) + self.assertIn(manifest["automated_validation_status"], {"production_ready", "drawing_draft"}) + self.assertEqual("第一角法", manifest["projection_method"]) + self.assertEqual("A", manifest["drawing_revision"]) + self.assertGreaterEqual(len(manifest["parts"]), 3) + self.assertTrue(all(part["drawing_number"].startswith("DLX-") for part in manifest["parts"])) + self.assertTrue(all(len(part["orthographic_views"]) == 3 for part in manifest["parts"])) + side_part = next(part for part in manifest["parts"] if part["part_id"] == "SIDE-001") + self.assertEqual({"length": 776.0, "width": 400.0, "thickness": 12.0}, side_part["manufacturing_size_mm"]) + self.assertEqual("x", side_part["thickness_axis"]) + pdf_path = pathlib.Path(files["production_drawing_pdf_path"]) + self.assertTrue(pdf_path.read_bytes().startswith(b"%PDF")) + dxf_path = pathlib.Path(files["production_drawing_dxf_path"]) + with zipfile.ZipFile(dxf_path) as archive: + names = archive.namelist() + self.assertIn("production-drawings.json", names) + self.assertTrue(any(name.endswith(".dxf") for name in names)) + dxf_name = next(name for name in names if name.endswith(".dxf")) + self.assertIn(b"$INSUNITS", archive.read(dxf_name)) + dxf_payloads = [archive.read(name) for name in names if name.endswith(".dxf")] + self.assertTrue(any(b"EDGE_DRILL_NOTE" in payload for payload in dxf_payloads)) + self.assertTrue(any(b"SIDE-DRILL" in payload for payload in dxf_payloads)) + quote["production_drawing"] = manifest + quote_files = self.engine._write_quote_files(job_dir, project["project_id"], quote) + workbook = load_workbook(quote_files["bom_xlsx_path"], data_only=True) + self.assertIn("生产图发布检查", workbook.sheetnames) + self.assertEqual("基准", workbook["连接孔槽明细"]["H1"].value) + + def test_production_drawing_feature_table_uses_continuation_pages_without_truncation_blocker(self): + holes = [ + { + "feature_id": f"H-{index:02d}", + "axis": "z", + "diameter_mm": 5.0, + "depth_mm": 8.0, + "center_mm": {"x": 20.0 + index * 10.0, "y": 20.0, "z": 6.0}, + "face": "top", + "validation_status": "valid", + } + for index in range(30) + ] + analysis = self.engine._normalize_analysis( + { + "product_name": "多孔位测试柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 800, "y": 400, "z": 800}, + "components": [ + { + "part_id": "TOP-MULTI-001", + "name": "多孔顶板", + "quantity": 1, + "dimensions_mm": {"x": 800, "y": 400, "z": 12}, + "confidence": 0.95, + "manufacturing_features": { + "coordinate_system": {"datum": "左-前-下角"}, + "holes": holes, + "grooves": [], + }, + } + ], + } + ) + selections = { + "material_structure": "engineered_panel_all", + "panel_code": "MDF_UNGRADED", + "main_species": "无等级密度板(MDF)", + "panel_thickness_mode": "uniform", + "panel_thickness_mm": 12.0, + "emission_grade": "UNGRADED", + "panel_purchase_state": "prefinished", + "finish": "三聚氰胺双饰面", + "edge_code": "PVC_0_5", + "edge_scope": "exposed_edges", + "hardware_grade": "basic", + "batch_qty": 50, + "shipment_mode": "拆装", + "shipping_scenario": "电商快递", + } + project = self.engine._new_project("production-drawing-pagination") + project = self.engine._update_project( + project["project_id"], "confirmed", "confirmed", analysis=analysis, selections=selections + ) + quote = self.engine._calculate_quote(project) + quote_part = next(item for item in quote["material_lines"] if item["part_id"] == "TOP-MULTI-001") + quote_part["manufacturing_features"] = { + "coordinate_system": {"datum": "左-前-下角"}, + "holes": holes, + "grooves": [], + } + manifest = self.engine._manufacturing_drawing_manifest(analysis, quote) + part = next(item for item in manifest["parts"] if item["part_id"] == "TOP-MULTI-001") + self.assertEqual(2, part["feature_table_page_count"]) + self.assertFalse( + any(item.get("code") == "drawing_feature_table_overflow" for item in manifest["release_blockers"]) + ) + job_dir = pathlib.Path(self.temp.name) / "production-drawing-pagination" + job_dir.mkdir() + artifacts = self.engine._write_manufacturing_drawings( + job_dir, project["project_id"], analysis, quote + ) + self.assertEqual(3, artifacts["production_drawing_manifest"]["pdf_page_count"]) + + def test_v29_bad_cabinet_axes_are_normalized_before_cad_and_hardware_costing(self): + def evidence(x_source, y_source, z_source): + return { + "x": {"source": x_source, "confidence": 0.95}, + "y": {"source": y_source, "confidence": 0.95}, + "z": {"source": z_source, "confidence": 0.95}, + } + + normalized = self.engine._normalize_analysis( + { + "product_name": "两门实木边柜", + "product_type": "柜类", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, + "door_mechanism": "hinged", + "door_count": 0, + "door_dimensions_mm": { + "leaf_widths": [578, 578], "leaf_height": 716, + "source": "尺寸图门片标注", "confidence": 0.95, + }, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "dimension_evidence": evidence("尺寸图顶板宽", "尺寸图顶板深", "尺寸图板厚")}, + {"part_id": "BOT-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "dimension_evidence": evidence("尺寸图底板宽", "尺寸图底板深", "尺寸图板厚")}, + {"part_id": "SID-001", "name": "侧板(左)", "quantity": 1, "dimensions_mm": {"x": 20, "y": 720, "z": 400}, "dimension_evidence": evidence("尺寸图侧板厚", "尺寸图侧板高", "尺寸图侧板深")}, + {"part_id": "SID-002", "name": "侧板(右)", "quantity": 1, "dimensions_mm": {"x": 20, "y": 720, "z": 400}, "dimension_evidence": evidence("尺寸图侧板厚", "尺寸图侧板高", "尺寸图侧板深")}, + {"part_id": "FRN-001", "name": "前挡板(左)", "quantity": 1, "dimensions_mm": {"x": 578, "y": 20, "z": 716}, "dimension_evidence": evidence("尺寸图门片宽", "尺寸图门片厚", "尺寸图门片高")}, + {"part_id": "FRN-002", "name": "前挡板(右)", "quantity": 1, "dimensions_mm": {"x": 578, "y": 20, "z": 716}, "dimension_evidence": evidence("尺寸图门片宽", "尺寸图门片厚", "尺寸图门片高")}, + {"part_id": "BCK-001", "name": "背板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 760}, "dimension_evidence": evidence("尺寸图背板宽", "尺寸图背板厚", "尺寸图背板高")}, + {"part_id": "SHF-001", "name": "搁板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 20, "z": 716}, "dimension_evidence": evidence("尺寸图搁板宽", "估算搁板厚", "反推搁板高")}, + {"part_id": "LEG-001", "name": "腿", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 400}, "dimension_evidence": evidence("尺寸图腿宽", "尺寸图腿深", "尺寸图腿高")}, + {"part_id": "HW-001", "name": "铰链", "shape": "hardware", "quantity": 4, "dimensions_mm": {"x": 1, "y": 1, "z": 1}}, + {"part_id": "HW-002", "name": "拉手", "shape": "hardware", "quantity": 2, "dimensions_mm": {"x": 1, "y": 1, "z": 1}}, + ], + } + ) + by_id = {item["part_id"]: item for item in normalized["components"]} + self.assertEqual({"x": 20.0, "y": 400.0, "z": 720.0}, by_id["SID-001"]["dimensions_mm"]) + self.assertEqual({"x": 1160.0, "y": 380.0, "z": 20.0}, by_id["SHF-001"]["dimensions_mm"]) + self.assertEqual({"x": 40.0, "y": 40.0, "z": 40.0}, by_id["LEG-001"]["dimensions_mm"]) + self.assertEqual({"x": 1160.0, "y": 9.0, "z": 720.0}, by_id["BCK-001"]["dimensions_mm"]) + self.assertEqual(2, normalized["door_count"]) + self.assertEqual(["柜门(左)", "柜门(右)"], [by_id["FRN-001"]["name"], by_id["FRN-002"]["name"]]) + door_origins = [by_id["FRN-001"]["origins_mm"][0], by_id["FRN-002"]["origins_mm"][0]] + self.assertGreater(door_origins[0]["x"], 20.0) + self.assertLess(door_origins[1]["x"] + 578.0, 1180.0) + self.assertEqual([], self.engine._deterministic_cad_review_issues(normalized)) + hardware = self.engine._hardware_costing(normalized, {}, door_count=2, drawer_count=0) + quantities = {line["name"]: int(line["quantity"]) for line in hardware["lines"]} + self.assertEqual(4, quantities["柜门铰链"]) + self.assertEqual(2, quantities["门抽拉手"]) + without_back = json.loads(json.dumps(normalized, ensure_ascii=False)) + without_back["components"] = [ + item for item in without_back["components"] if item["part_id"] != "BCK-001" + ] + restored = self.engine._add_confirmed_back_panel(without_back, 9.0) + restored_back = next(item for item in restored["components"] if item["part_id"] == "BACK-001") + self.assertEqual({"x": 1160.0, "y": 9.0, "z": 720.0}, restored_back["dimensions_mm"]) + self.assertEqual({"x": 20.0, "y": 391.0, "z": 60.0}, restored_back["origins_mm"][0]) + self.assertEqual([], self.engine._deterministic_cad_review_issues(restored)) + + def test_side_depth_cannot_copy_product_width_past_overall_depth(self): + def evidence(source): + return {"source": source, "confidence": 0.95} + + normalized = self.engine._normalize_analysis( + { + "product_name": "带抽屉书柜", + "product_type": "书柜", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "dimension_evidence": { + "x": {"value": 480, "source": "尺寸图整体宽", "confidence": 0.98}, + "y": {"value": 300, "source": "尺寸图整体深", "confidence": 0.98}, + "z": {"value": 1600, "source": "尺寸图整体高", "confidence": 0.98}, + }, + "has_doors": False, + "drawer_count": 0, + "components": [ + { + "part_id": "SIDE-001", + "name": "侧板", + "quantity": 2, + "dimensions_mm": {"x": 12, "y": 480, "z": 1566}, + "dimension_evidence": { + "x": evidence("尺寸图侧板厚"), + "y": evidence("视觉估算侧板深"), + "z": evidence("视觉估算侧板高"), + }, + } + ], + } + ) + side = normalized["components"][0] + self.assertEqual(300.0, side["dimensions_mm"]["y"]) + self.assertIn("超过产品整体深度", side["dimension_evidence"]["y"]["source"]) + self.assertLessEqual(side["dimension_evidence"]["y"]["confidence"], 0.55) + + def test_dimension_schedule_overrides_visual_quantity_and_restores_explicit_parts(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "两门实木边柜", "product_type": "柜类", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, "door_mechanism": "hinged", + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "BOT-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 400, "z": 720}}, + # The general vision pass guessed two shelves. The exact + # engineering schedule below says one and must win. + {"part_id": "SHELF-001", "name": "层板", "quantity": 2, "dimensions_mm": {"x": 1160, "y": 380, "z": 20}}, + {"part_id": "SHELF-002", "name": "层板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 380, "z": 20}}, + {"part_id": "DOOR-001", "name": "柜门", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 716}}, + # The visual pass hallucinated the complete closed front as + # one more solid panel. It is absent from the exact + # schedule and overlaps the separately listed doors. + {"part_id": "FRN-001", "name": "前挡板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 20, "z": 720}}, + {"part_id": "LEG-001", "name": "支腿", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 40}}, + ], + } + ) + schedule = { + "table_present": True, "confidence": 0.99, + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "rows": [ + {"role": "top", "source_name": "Top", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "source_text": "Top 1200x400x20 (1)", "confidence": 0.99}, + {"role": "bottom", "source_name": "Bottom", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "source_text": "Bottom 1200x400x20 (1)", "confidence": 0.99}, + {"role": "side", "source_name": "Side", "quantity": 2, "dimensions_mm": {"x": 20, "y": 400, "z": 720}, "source_text": "Side 20x400x720 (2)", "confidence": 0.99}, + {"role": "shelf", "source_name": "Shelf", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 380, "z": 20}, "source_text": "Shelf 1160x380x20 (1)", "confidence": 0.99}, + {"role": "door", "source_name": "Door", "quantity": 2, "dimensions_mm": {"x": 578, "y": 20, "z": 716}, "source_text": "Door 578x20x716 (2)", "confidence": 0.99}, + {"role": "back", "source_name": "Back", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 9, "z": 720}, "source_text": "Back 1160x9x720 (1)", "confidence": 0.99}, + {"role": "leg", "source_name": "Leg", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 40}, "source_text": "Leg 40x40x40 (4)", "confidence": 0.99}, + ], + "hardware": [ + {"name": "hinge", "quantity": 4, "source_text": "Hardware hinge (4)", "confidence": 0.99}, + {"name": "hinge", "quantity": 4, "source_text": "Hardware hinge (4)", "confidence": 0.99}, + {"name": "knob", "quantity": 2, "source_text": "Hardware knob (2)", "confidence": 0.99}, + ], + } + reconciled = self.engine._apply_dimension_schedule(analysis, schedule) + by_role = { + role: [item for item in reconciled["components"] if self.engine._component_schedule_role(item) == role] + for role in ("top", "bottom", "side", "shelf", "door", "back", "leg") + } + self.assertEqual(1, sum(int(item["quantity"]) for item in by_role["shelf"])) + self.assertEqual(1, sum(int(item["quantity"]) for item in by_role["back"])) + self.assertEqual(2, sum(int(item["quantity"]) for item in by_role["door"])) + self.assertEqual(4, sum(int(item["quantity"]) for item in by_role["leg"])) + self.assertEqual({"铰链": 4, "拉手": 2}, {item["name"]: item["quantity"] for item in reconciled["recognized_hardware"]}) + self.assertEqual({"x": 1160.0, "y": 9.0, "z": 720.0}, by_role["back"][0]["dimensions_mm"]) + self.assertFalse(any(item["part_id"] == "FRN-001" for item in reconciled["components"])) + self.assertEqual("FRN-001", reconciled["rejected_visual_components"][0]["part_id"]) + self.assertEqual([], self.engine._deterministic_cad_review_issues(reconciled)) + + bypassed = json.loads(json.dumps(reconciled, ensure_ascii=False)) + bypassed["components"].append( + {"part_id": "FRN-BYPASS", "name": "前挡板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 20, "z": 720}} + ) + self.assertIn( + "front_facade_duplicate_with_doors", + {item["code"] for item in self.engine._deterministic_cad_review_issues(bypassed)}, + ) + + def test_multimodal_cad_false_missing_and_axis_claims_are_rejected_by_schedule(self): + analysis = { + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}}, + {"part_id": "SHELF-001", "name": "层板", "quantity": 1, "dimensions_mm": {"x": 1160, "y": 380, "z": 20}}, + ], + "dimension_schedule": { + "rows": [ + {"role": "top", "quantity": 1, "dims": {"x": 1200, "y": 400, "z": 20}}, + {"role": "shelf", "quantity": 1, "dims": {"x": 1160, "y": 380, "z": 20}}, + ] + }, + } + self.assertTrue(self.engine._ai_cad_issue_is_contradicted( + analysis, {"code": "shelf_missing", "component": "层板", "description": "CAD中缺少层板"} + )) + self.assertTrue(self.engine._ai_cad_issue_is_contradicted( + analysis, {"code": "top_height_mismatch", "component": "顶板", "description": "顶板尺寸400与产品高800冲突"} + )) + self.assertFalse(self.engine._ai_cad_issue_is_contradicted( + analysis, {"code": "hinge_template_missing", "component": "铰链", "description": "铰链未在CAD中建模"} + )) + full_cabinet = { + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "origins_mm": [{"x": 0, "y": 0, "z": 780}], "origin_source": "标准柜体产品坐标装配布局"}, + {"part_id": "BOT", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "origins_mm": [{"x": 0, "y": 0, "z": 40}], "origin_source": "标准柜体产品坐标装配布局"}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 400, "z": 720}, "origins_mm": [{"x": 0, "y": 0, "z": 60}, {"x": 1180, "y": 0, "z": 60}], "origin_source": "标准柜体产品坐标装配布局"}, + {"part_id": "LEG", "name": "支腿", "quantity": 4, "dimensions_mm": {"x": 40, "y": 40, "z": 40}, "origins_mm": [{"x": 0, "y": 0, "z": 0}, {"x": 1160, "y": 0, "z": 0}, {"x": 0, "y": 360, "z": 0}, {"x": 1160, "y": 360, "z": 0}], "origin_source": "标准柜体产品坐标装配布局"}, + ], + } + self.assertTrue(self.engine._ai_cad_issue_is_contradicted( + full_cabinet, {"code": "height_mismatch", "component": "柜体高度", "description": "原始整体高度800mm,CAD仅显示400mm"} + )) + shelf_supported = json.loads(json.dumps(analysis, ensure_ascii=False)) + shelf_supported["production_joinery"] = {"shelf_support": {"enabled": True}} + self.assertTrue(self.engine._ai_cad_issue_is_contradicted( + shelf_supported, {"code": "shelf_support_missing", "component": "层板承托", "description": "CAD中未见层板支撑"} + )) + estimated_drawer = { + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "estimation_consent": True, + "user_topology_constraints": {"source_text": "同意抽屉箱体按CAD合理值预估"}, + "components": [ + {"part_id": "LOWER-DIVIDER", "name": "下柜中间竖隔板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 400, "z": 561}}, + {"part_id": "DRAWER-F", "name": "抽屉面板", "quantity": 1, "dimensions_mm": {"x": 588, "y": 12, "z": 153}}, + {"part_id": "DRAWER-L", "name": "第1个抽屉左帮板(左侧板)", "quantity": 1, "dimensions_mm": {"x": 12, "y": 364, "z": 153}}, + {"part_id": "DRAWER-R", "name": "第1个抽屉右帮板(右侧板)", "quantity": 1, "dimensions_mm": {"x": 12, "y": 364, "z": 153}}, + {"part_id": "DRAWER-BACK", "name": "第1个抽屉后帮板(后侧板)", "quantity": 1, "dimensions_mm": {"x": 564, "y": 12, "z": 153}}, + {"part_id": "DRAWER-BOTTOM", "name": "第1个抽屉底板", "quantity": 1, "dimensions_mm": {"x": 564, "y": 364, "z": 12}}, + ], + } + self.assertTrue(self.engine._ai_cad_issue_is_contradicted( + estimated_drawer, + {"code": "missing-part", "component": "下柜中间竖隔板 (LOWER-DIVIDER)", "description": "BOM有但CAD缺失"}, + )) + self.assertTrue(self.engine._ai_cad_issue_is_contradicted( + estimated_drawer, + {"code": "dimension-mismatch", "component": "抽屉底板", "description": "深度364mm与柜深400mm矛盾"}, + )) + + def test_truncated_dimension_schedule_json_salvages_complete_rows(self): + malformed = ''' + {"table_present":true,"confidence":0.98, + "overall_dimensions_mm":{"x":1200,"y":400,"z":800}, + "rows":[ + {"role":"top","source_name":"Top","quantity":1,"dimensions_mm":{"x":1200,"y":400,"z":20},"source_text":"Top 1200x400x20 (1)","confidence":0.99} + {"role":"shelf","source_name":"Shelf","quantity":1,"dimensions_mm":{"x":1160,"y":380,"z":20},"source_text":"Shelf 1160x380x20 (1)","confidence":0.99} + ], + "hardware":[{"name":"hinge","quantity":4,"source_text":"hinge (4)","confidence":0.99}] + ''' + decoded = self.engine._decode_dimension_schedule_json(malformed) + self.assertTrue(decoded["table_present"]) + self.assertEqual(2, len(decoded["rows"])) + self.assertEqual("shelf", decoded["rows"][1]["role"]) + self.assertEqual(4, decoded["hardware"][0]["quantity"]) + + def test_deterministic_cad_gate_blocks_axis_bounds_overlap_and_missing_doors(self): + bad = { + "product_name": "错误柜体", "product_type": "柜类", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 800}, + "has_doors": True, "door_mechanism": "hinged", "door_count": 0, + "door_dimensions_mm": {"leaf_widths": [578, 578], "leaf_height": 716}, + "components": [ + {"part_id": "TOP-001", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "origins_mm": [{"x": 0, "y": 0, "z": 780}]}, + {"part_id": "BOT-001", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 20}, "origins_mm": [{"x": 0, "y": 0, "z": 780}]}, + {"part_id": "SIDE-001", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 20, "y": 720, "z": 400}, "origins_mm": [{"x": 0, "y": 0, "z": 20}, {"x": 1180, "y": 0, "z": 20}]}, + {"part_id": "BAD-A", "name": "未分类零件A", "quantity": 1, "dimensions_mm": {"x": 100, "y": 100, "z": 100}, "origins_mm": [{"x": 500, "y": 100, "z": 300}]}, + {"part_id": "BAD-B", "name": "未分类零件B", "quantity": 1, "dimensions_mm": {"x": 100, "y": 100, "z": 100}, "origins_mm": [{"x": 500, "y": 100, "z": 300}]}, + ], + } + codes = {item["code"] for item in self.engine._deterministic_cad_review_issues(bad)} + self.assertIn("door_part_missing", codes) + self.assertIn("door_quantity_mismatch", codes) + self.assertIn("side_panel_depth_out_of_bounds", codes) + self.assertIn("assembled_part_exact_overlap", codes) + + def test_connection_rule_and_hardware_template_versions_are_seeded_once(self): + self.module.SolidWoodCostEngine() + with self.engine._connect() as conn: + rule_versions = conn.execute( + "SELECT COUNT(*) FROM connection_rule_versions WHERE version_label=?", + (self.engine.CONNECTION_RULE_VERSION,), + ).fetchone()[0] + template_versions = conn.execute( + "SELECT COUNT(*) FROM hardware_drilling_template_versions WHERE version_label=?", + (self.engine.HARDWARE_TEMPLATE_VERSION,), + ).fetchone()[0] + blocked_panel_mortise = conn.execute( + """ + SELECT is_allowed FROM material_connection_rules r + JOIN connection_rule_versions v ON v.version_id=r.version_id + WHERE v.version_label=? AND r.material_family='engineered_panel' + AND r.process_code='traditional_mortise_tenon' + """, + (self.engine.CONNECTION_RULE_VERSION,), + ).fetchone()[0] + self.assertEqual(1, rule_versions) + self.assertEqual(1, template_versions) + self.assertEqual(0, blocked_panel_mortise) + + def test_v48_explicit_bookshelf_topology_removes_stale_sets_and_keeps_dimensions(self): + raw = { + "product_name": "48cm开放书架", "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "has_doors": False, "door_mechanism": "none", "drawer_count": 2, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 300, "z": 12}}, + {"part_id": "BOT", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 300, "z": 12}}, + {"part_id": "SIDE-L", "name": "左侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 300, "z": 1600}}, + {"part_id": "SIDE-R", "name": "右侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 300, "z": 1600}}, + *[ + {"part_id": f"SHELF-{index}", "name": f"第{index}层隔板", "quantity": 1, "dimensions_mm": {"x": 398, "y": 200, "z": 12}} + for index in range(1, 6) + ], + {"part_id": "DF-U", "name": "上层抽屉面板", "quantity": 1, "dimensions_mm": {"x": 376, "y": 12, "z": 168}}, + {"part_id": "DF-L", "name": "下层抽屉面板", "quantity": 1, "dimensions_mm": {"x": 376, "y": 12, "z": 168}}, + {"part_id": "DL", "name": "抽屉左侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 264, "z": 168}}, + {"part_id": "DR", "name": "抽屉右侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 264, "z": 168}}, + {"part_id": "DB", "name": "抽屉后板", "quantity": 2, "dimensions_mm": {"x": 352, "y": 12, "z": 168}}, + {"part_id": "D-BOT", "name": "抽屉底板", "quantity": 2, "dimensions_mm": {"x": 352, "y": 264, "z": 12}}, + ], + } + analysis = self.engine._normalize_analysis(raw) + message = ( + "整体480×300×1600mm,所有板件12mm;开放书架没有柜门,前方没有整高挡板;" + "上部共5块层板,每块层板398×200×12mm;底部2个上下叠放的完整抽屉," + "每个抽屉面板376×12×168mm,侧板有R30圆角。" + ) + updated = self.engine._apply_user_structure_overrides(analysis, message) + # A later resume/continue turn must be idempotent and must not erase the + # exact dimensions supplied in the earlier topology confirmation. + updated = self.engine._apply_user_structure_overrides(updated, "同意对未标注尺寸按CAD合理值预估") + self.assertEqual(5, sum(int(item["quantity"]) for item in updated["components"] if self.engine._is_shelf_panel(item))) + shelf = next(item for item in updated["components"] if self.engine._is_shelf_panel(item)) + self.assertEqual({"x": 398.0, "y": 200.0, "z": 12.0}, shelf["dimensions_mm"]) + self.assertEqual(2, self.engine._drawer_front_count(updated["components"])) + for role in ("left", "right", "back", "bottom"): + self.assertEqual( + 2, + sum(int(item["quantity"]) for item in updated["components"] if self.engine._drawer_box_role(item) == role), + ) + fronts = [item for item in updated["components"] if self.engine._is_drawer_front(item)] + self.assertTrue(all(item["dimensions_mm"] == {"x": 376.0, "y": 12.0, "z": 168.0} for item in fronts)) + instances = self.engine._component_instances(updated) + self.assertEqual( + 1600.0, + max( + item["assembled_position_mm"]["z"] + item["dimensions_mm"]["z"] + for item in instances + ), + ) + segmentation_issues = self.engine._cad_review_view_issues( + updated, + { + "view_findings": [ + { + "asset_id": 901, + "declared_role": "dimension", + "observed_role": "dimension", + "confidence": 0.96, + "dimension_confidence": 0.96, + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "side_panel_segments_per_side": 3, + "side_panel_segment_heights_mm": [480, 554, 554], + "side_panel_segmentation_confidence": 0.94, + "evidence": "尺寸图有两条模块接缝,并标注48、55.4、55.4cm", + } + ] + }, + [{"asset_id": 901, "role": "dimension"}], + ) + self.assertIn( + "source_side_panel_segmentation_mismatch", + {item["code"] for item in segmentation_issues}, + ) + segmented = self.engine._apply_user_structure_overrides( + updated, + "确认左右侧板每侧分为3段,分段高度按480、554、554mm;" + "下段深300mm,中上段深200mm,侧板R30。", + ) + segmented_sides = [ + item for item in segmented["components"] + if "侧板" in item["name"] and "抽屉" not in item["name"] + ] + self.assertEqual(6, len(segmented_sides)) + self.assertEqual( + [0.0, 480.0, 1034.0], + sorted({item["origins_mm"][0]["z"] for item in segmented_sides}), + ) + self.assertNotIn( + "source_side_panel_segmentation_mismatch", + { + item["code"] + for item in self.engine._cad_review_view_issues( + segmented, + { + "view_findings": [ + { + "asset_id": 901, + "declared_role": "dimension", + "observed_role": "dimension", + "confidence": 0.96, + "dimension_confidence": 0.96, + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "side_panel_segments_per_side": 3, + "side_panel_segment_heights_mm": [480, 554, 554], + "side_panel_segmentation_confidence": 0.94, + "evidence": "尺寸图有两条模块接缝", + } + ] + }, + [{"asset_id": 901, "role": "dimension"}], + ) + }, + ) + shelf_origins = [item["assembled_position_mm"] for item in instances if "SHELF" in item["part_id"]] + self.assertEqual(5, len({round(item["z"], 3) for item in shelf_origins})) + drawer_front_origins = [item["assembled_position_mm"] for item in instances if self.engine._is_drawer_front({"name": item["part_name"]})] + self.assertEqual(2, len({round(item["z"], 3) for item in drawer_front_origins})) + self.assertEqual([12.0, 180.0], sorted(round(item["z"], 3) for item in drawer_front_origins)) + self.assertTrue(all(item["y"] == 100.0 for item in shelf_origins)) + self.assertTrue(all(item["z"] >= 480.0 for item in shelf_origins)) + side = next(item for item in updated["components"] if item["name"] == "左侧板") + self.assertEqual("stepped_front_radius", side["profile"]["type"]) + self.assertEqual(480.0, side["profile"]["lower_height_mm"]) + net_volume, face_area = self.engine._component_net_geometry(side) + self.assertAlmostEqual(0.368, face_area, places=6) + self.assertAlmostEqual(0.004416, net_volume, places=6) + source = self.engine._source_for_components(updated, layout="assembled") + self.assertIn("fillet(", source) + self.assertIn("_lower = Box", source) + compile(source, "", "exec") + codes = {item["code"] for item in self.engine._deterministic_cad_review_issues(updated)} + self.assertNotIn("assembled_part_exact_overlap", codes) + for item in updated["components"]: + if self.engine._is_drawer_front(item): + item["confirmed_thickness_mm"] = 12.0 + self.assertTrue( + self.engine._ai_cad_issue_is_contradicted( + updated, + { + "code": "DIMENSION_CONFLICT", + "component": "抽屉面板", + "description": "抽屉面板厚度标注15mm,与BOM的12mm不一致", + "evidence": "原始图片竖向标注15CM", + }, + ) + ) + corrected = json.loads(json.dumps(updated, ensure_ascii=False)) + self.assertTrue( + self.engine._apply_user_component_dimensions( + corrected, + "修正尺寸:左右两个抽屉面板均为588×12×150mm", + ) + ) + self.assertTrue( + all( + item["dimensions_mm"]["z"] == 150.0 + for item in corrected["components"] + if self.engine._drawer_box_role(item) in {"front", "left", "right", "back"} + ) + ) + previous = json.loads(json.dumps(updated, ensure_ascii=False)) + previous["structure_decisions"] = { + "cabinet_carcass": {"completed": True}, + "back_panel": {"present": False}, + } + refreshed = { + "product_name": previous["product_name"], + "product_type": previous["product_type"], + "overall_dimensions_mm": previous["overall_dimensions_mm"], + "components": [ + item for item in previous["components"] + if self.engine._is_drawer_front(item) + ], + } + restored = self.engine._restore_confirmed_topology_after_reanalysis(previous, refreshed) + self.assertEqual(5, sum(int(item["quantity"]) for item in restored["components"] if self.engine._is_shelf_panel(item))) + for required in previous["user_topology_constraints"]["required_parts"]: + self.assertTrue(any(required in item["name"] for item in restored["components"])) + self.assertFalse(any(self.engine._is_back_panel(item) for item in restored["components"])) + self.assertFalse(self.engine._cabinet_carcass_missing(restored)) + self.assertTrue( + self.engine._ai_cad_issue_is_contradicted( + updated, + { + "code": "dimension_mismatch", + "component": "抽屉面板", + "description": "抽面高度标注15CM与实际150mm不符", + "evidence": "正视图竖向标注15CM", + }, + ) + ) + + def test_v48_toe_kick_is_not_a_leg_and_drawer_divider_stays_in_bounds(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "120cm推拉餐柜", "product_type": "餐柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "has_doors": True, "door_mechanism": "sliding", "drawer_count": 2, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "BOT", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 850}}, + {"part_id": "DOOR", "name": "推拉门", "quantity": 2, "dimensions_mm": {"x": 600, "y": 12, "z": 540}}, + {"part_id": "DF-L", "name": "左抽屉面板", "quantity": 1, "dimensions_mm": {"x": 580, "y": 12, "z": 153}}, + {"part_id": "DF-R", "name": "右抽屉面板", "quantity": 1, "dimensions_mm": {"x": 580, "y": 12, "z": 153}}, + {"part_id": "HD", "name": "横隔板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 12, "z": 12}}, + ], + } + ) + message = ( + "整体1200×400×850mm,2扇左右推拉门,2个左右并排的完整抽屉," + "下柜左右两格各1块层板(共2块层板),下柜中间1块竖隔板," + "抽屉下方1块通宽横隔板,两个抽屉之间1块竖向中隔板,1200×100mm踢脚板。" + ) + updated = self.engine._apply_user_structure_overrides(analysis, message) + toe = next(item for item in updated["components"] if "踢脚" in item["name"]) + self.assertFalse(self.engine._is_leg_component(toe)) + self.assertEqual({"x": 1200.0, "y": 12.0, "z": 100.0}, toe["dimensions_mm"]) + self.assertEqual(1, sum("横隔板" in item["name"] for item in updated["components"])) + self.assertEqual("horizontal", updated["user_topology_constraints"]["drawer_layout"]) + shelf = next(item for item in updated["components"] if self.engine._is_shelf_panel(item)) + self.assertEqual(2, shelf["quantity"]) + self.assertEqual(582.0, shelf["dimensions_mm"]["x"]) + instances = self.engine._component_instances(updated) + shelf_instances = [item for item in instances if self.engine._is_shelf_panel({"name": item["part_name"]})] + self.assertEqual([9.0, 609.0], sorted(item["assembled_position_mm"]["x"] for item in shelf_instances)) + self.assertEqual(1, len({round(item["assembled_position_mm"]["z"], 3) for item in shelf_instances})) + lower_divider = next(item for item in instances if item["part_name"] == "下柜中间竖隔板") + self.assertEqual(112.0, lower_divider["assembled_position_mm"]["z"]) + codes = {item["code"] for item in self.engine._deterministic_cad_review_issues(updated)} + self.assertNotIn("assembled_part_out_of_bounds", codes) + selections = { + "material_structure": "engineered_panel_all", "material_family": "engineered_panel", + "panel_code": "MDF_UNGRADED", "main_species": "无等级密度板(MDF)", + "aux_species": "无等级密度板(MDF)", "all_same": True, + "panel_thickness_mode": "uniform", "panel_thickness_mm": 12.0, + } + joined = self.engine._apply_production_joinery(updated, selections) + validation_codes = {item.get("code") for item in joined["production_joinery"].get("validation_issues") or []} + self.assertNotIn("leg_connection_unspecified", validation_codes) + + def test_v50_horizontal_drawer_rows_get_distinct_instances_and_cm_height(self): + drawer_evidence = { + "x": {"value": 580, "source": "尺寸图抽屉宽度", "confidence": 0.95}, + "y": {"value": 15, "source": "尺寸图抽屉深度", "confidence": 0.95}, + "z": {"value": 12, "source": "尺寸图板厚", "confidence": 0.95}, + } + analysis = self.engine._normalize_analysis( + { + "product_name": "120cm推拉餐柜", "product_type": "餐柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "has_doors": True, "door_mechanism": "sliding", "drawer_count": 2, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "BOT", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "DF-L", "name": "左侧抽屉面板", "quantity": 1, "dimensions_mm": {"x": 540, "y": 15, "z": 12}, "dimension_evidence": drawer_evidence}, + {"part_id": "DF-R", "name": "右侧抽屉面板", "quantity": 1, "dimensions_mm": {"x": 580, "y": 15, "z": 12}, "dimension_evidence": drawer_evidence}, + {"part_id": "DL-1", "name": "抽屉左帮板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 364, "z": 100}}, + {"part_id": "DL-2", "name": "抽屉左帮板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 364, "z": 100}}, + {"part_id": "DR-1", "name": "抽屉右帮板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 364, "z": 100}}, + {"part_id": "DR-2", "name": "抽屉右帮板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 364, "z": 100}}, + {"part_id": "DB-1", "name": "抽屉后帮板", "quantity": 1, "dimensions_mm": {"x": 564, "y": 12, "z": 100}}, + {"part_id": "DB-2", "name": "抽屉后帮板", "quantity": 1, "dimensions_mm": {"x": 564, "y": 12, "z": 100}}, + {"part_id": "DX-1", "name": "抽屉底板", "quantity": 1, "dimensions_mm": {"x": 564, "y": 364, "z": 12}}, + {"part_id": "DX-2", "name": "抽屉底板", "quantity": 1, "dimensions_mm": {"x": 564, "y": 364, "z": 12}}, + ], + } + ) + fronts = [item for item in analysis["components"] if self.engine._is_drawer_front(item)] + self.assertTrue(all(item["dimensions_mm"]["z"] == 150.0 for item in fronts)) + updated = self.engine._apply_user_structure_overrides( + analysis, + "2个左右并排的完整抽屉,未标注的抽屉箱体尺寸按CAD合理值预估", + ) + self.assertEqual( + 1, + len([item for item in updated["components"] if item["name"] == "抽屉左帮板(左侧板)"]), + ) + left = next(item for item in updated["components"] if item["name"] == "抽屉左帮板(左侧板)") + self.assertEqual(2, left["quantity"]) + instances = self.engine._component_instances(updated) + exploded_positions = { + tuple(item["exploded_position_mm"][axis] for axis in ("x", "y", "z")) + for item in instances + } + self.assertEqual(len(instances), len(exploded_positions)) + exploded_width = max( + item["exploded_position_mm"]["x"] + item["dimensions_mm"]["x"] + for item in instances + ) + self.assertLessEqual(exploded_width, 4800.0) + for role in ("left", "right", "back", "bottom"): + origins = [ + tuple(item["assembled_position_mm"][axis] for axis in ("x", "y", "z")) + for item in instances + if self.engine._drawer_box_role({"name": item["part_name"]}) == role + ] + self.assertEqual(2, len(origins)) + self.assertEqual(2, len(set(origins))) + codes = {item["code"] for item in self.engine._deterministic_cad_review_issues(updated)} + self.assertNotIn("assembled_part_exact_overlap", codes) + + def test_v59_uniform_thickness_keeps_confirmed_modular_side_origins(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "48cm开放书架", + "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 200, "z": 12}}, + {"part_id": "BOT", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 300, "z": 12}}, + {"part_id": "SIDE-L", "name": "左侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 300, "z": 1588}}, + {"part_id": "SIDE-R", "name": "右侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 300, "z": 1588}}, + ], + } + ) + segmented = self.engine._apply_user_structure_overrides( + analysis, + "左右侧板每侧分为3段,分段高度按480、554、554mm;下段深300mm,中上段深200mm。", + ) + adjusted = self.engine._analysis_for_selected_thickness( + segmented, + { + "material_structure": "engineered_panel_all", + "material_family": "engineered_panel", + "panel_thickness_mode": "uniform", + "panel_thickness_mm": 12.0, + }, + ) + sides = [ + item for item in adjusted["components"] + if item.get("module_segment_index") is not None + ] + self.assertEqual(6, len(sides)) + self.assertEqual([0.0, 480.0, 1034.0], sorted({item["origins_mm"][0]["z"] for item in sides})) + self.assertEqual([0.0, 468.0], sorted({item["origins_mm"][0]["x"] for item in sides})) + self.assertNotIn( + "assembled_part_exact_overlap", + {item["code"] for item in self.engine._deterministic_cad_review_issues(adjusted)}, + ) + + def test_v60_confirmed_sliding_door_schedule_cannot_swap_width_and_height(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "120cm推拉餐柜", + "product_type": "餐柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "has_doors": True, + "door_mechanism": "sliding", + "door_count": 2, + "door_dimensions_mm": { + "leaf_widths": [600, 600], + "leaf_height": 540, + "source": "尺寸图门片标注", + "confidence": 0.95, + }, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "BOT", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SIDE-L", "name": "左侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 400, "z": 850}}, + {"part_id": "SIDE-R", "name": "右侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 400, "z": 850}}, + {"part_id": "DOOR-L", "name": "柜门(左)", "quantity": 1, "dimensions_mm": {"x": 600, "y": 12, "z": 540}, "dimension_evidence": {"source": "尺寸图门片标注", "confidence": 0.95}}, + {"part_id": "DOOR-R", "name": "柜门(右)", "quantity": 1, "dimensions_mm": {"x": 600, "y": 12, "z": 540}, "dimension_evidence": {"source": "尺寸图门片标注", "confidence": 0.95}}, + ], + } + ) + adjusted = self.engine._analysis_for_selected_thickness( + analysis, + { + "material_structure": "engineered_panel_all", + "material_family": "engineered_panel", + "panel_thickness_mode": "uniform", + "panel_thickness_mm": 12.0, + }, + ) + doors = [item for item in self.engine._component_instances(adjusted) if self.engine._is_door_panel({"name": item["part_name"]})] + self.assertEqual(2, len(doors)) + self.assertTrue(all(item["dimensions_mm"] == {"x": 600.0, "y": 12.0, "z": 540.0} for item in doors)) + self.assertEqual([12.0, 588.0], sorted(item["assembled_position_mm"]["x"] for item in doors)) + self.assertNotIn( + "door_opening_fit_invalid", + {item["code"] for item in self.engine._deterministic_cad_review_issues(adjusted)}, + ) + + def test_user_explicit_product_envelope_wins_over_wrong_dimension_variant(self): + visual = { + "product_name": "办公桌", + "product_type": "桌类", + "overall_dimensions_mm": {"x": 1200, "y": 500, "z": 750}, + "has_doors": False, + "components": [ + { + "part_id": "DESK-TOP", + "name": "桌面", + "quantity": 1, + "dimensions_mm": {"x": 1200, "y": 500, "z": 20}, + }, + ], + } + wrong_variant_schedule = { + "table_present": True, + "confidence": 0.98, + "overall_dimensions_mm": {"x": 1600, "y": 800, "z": 750}, + "rows": [ + { + "role": "top", + "source_name": "顶板", + "source_text": "顶板 1600×800×20", + "quantity": 1, + "dimensions_mm": {"x": 1600, "y": 800, "z": 20}, + "confidence": 0.98, + }, + ], + "hardware": [], + } + with mock.patch.object(self.engine, "_request_model_json", return_value=visual), mock.patch.object( + self.engine, + "_extract_dimension_schedule", + return_value=wrong_variant_schedule, + ): + analyzed = self.engine._vision_analyze( + [{"role": "dimension"}], + "这是同一产品,产品整体尺寸按1200×600×750mm,先生成CAD复核。", + ) + self.assertEqual({"x": 1200.0, "y": 600.0, "z": 750.0}, analyzed["overall_dimensions_mm"]) + tops = [ + item for item in analyzed["components"] + if self.engine._component_schedule_role(item) == "top" + ] + self.assertEqual(1, len(tops), "桌面与尺寸表顶板不得成为两块重复板件") + self.assertEqual({"x": 1200.0, "y": 600.0, "z": 20.0}, tops[0]["dimensions_mm"]) + self.assertEqual("用户本轮明确整体尺寸", analyzed["dimension_evidence"]["x"]["source"]) + self.assertTrue(analyzed.get("dimension_conflicts")) + + def test_explicit_product_envelope_parser_accepts_cm_but_ignores_unlabelled_parts(self): + self.assertEqual( + {"x": 1200.0, "y": 400.0, "z": 850.0}, + self.engine._explicit_user_overall_dimensions("成品尺寸120×40×85cm"), + ) + self.assertIsNone( + self.engine._explicit_user_overall_dimensions("抽屉内部尺寸300×350×120mm"), + ) + + def test_v63_later_correction_keeps_left_right_shelf_total(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "120cm推拉餐柜", "product_type": "餐柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "BOT", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 850}}, + ], + } + ) + first = self.engine._apply_user_structure_overrides( + analysis, + "下柜左右两格各1块层板(共2块层板),共2个抽屉。", + ) + corrected = self.engine._apply_user_structure_overrides( + first, + "只有2个抽屉,下柜左右各1块层板,其他结构不变。", + ) + shelves = [item for item in corrected["components"] if self.engine._is_shelf_panel(item)] + self.assertEqual(1, len(shelves)) + self.assertEqual(2, shelves[0]["quantity"]) + self.assertEqual(2, corrected["user_topology_constraints"]["expected_counts"]["shelves"]) + self.assertEqual("lower_left_right", corrected["user_topology_constraints"]["shelf_layout"]) + + def test_v63_drawer_back_closes_side_depth_and_spatial_review_uses_exact_bounds(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "带抽屉餐柜", "product_type": "餐柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "drawer_count": 1, "estimation_consent": True, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "BOT", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 850}}, + {"part_id": "DF", "name": "抽屉面板", "quantity": 1, "dimensions_mm": {"x": 588, "y": 12, "z": 150}}, + {"part_id": "DL", "name": "抽屉左帮板(左侧板)", "quantity": 1, "dimensions_mm": {"x": 12, "y": 364, "z": 150}}, + {"part_id": "DR", "name": "抽屉右帮板(右侧板)", "quantity": 1, "dimensions_mm": {"x": 12, "y": 364, "z": 150}}, + {"part_id": "DB", "name": "抽屉后帮板(后侧板)", "quantity": 1, "dimensions_mm": {"x": 564, "y": 12, "z": 150}}, + {"part_id": "D0", "name": "抽屉底板", "quantity": 1, "dimensions_mm": {"x": 564, "y": 364, "z": 12}}, + ], + } + ) + instances = self.engine._component_instances(analysis) + drawer_back = next(item for item in instances if self.engine._drawer_box_role({"name": item["part_name"]}) == "back") + self.assertEqual(364.0, drawer_back["assembled_position_mm"]["y"]) + self.assertTrue( + self.engine._ai_cad_issue_is_contradicted( + analysis, + {"code": "drawer_structure_mismatch", "component": "抽屉底板", "description": "抽屉底板穿出柜体外部"}, + ) + ) + self.assertFalse( + self.engine._ai_cad_issue_is_contradicted( + analysis, + {"code": "drawer_structure_mismatch", "component": "抽屉底板", "description": "抽屉底板位置装配错位,与帮板冲突"}, + ) + ) + for item in analysis["components"]: + if self.engine._drawer_box_role(item) in {"front", "left", "right", "back"}: + item["manufacturing_features"] = { + "holes": [], + "grooves": [{"group": "drawer_bottom_groove", "validation_status": "valid"}], + } + self.assertTrue(self.engine._drawer_cad_geometry_consistent(analysis)) + self.assertTrue( + self.engine._ai_cad_issue_is_contradicted( + analysis, + { + "code": "assembly_conflict", + "component": "抽屉底板承托", + "description": "前板在装配图中位置不成立,底板可能悬空", + }, + ) + ) + + def test_v63_topology_geometry_reconciles_drawer_divider_after_front_height_changes(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "推拉柜", "product_type": "餐柜", + "overall_dimensions_mm": {"x": 1200, "y": 400, "z": 850}, + "drawer_count": 2, + "user_topology_constraints": {"drawer_layout": "horizontal", "toe_kick_height_mm": 100}, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "BOT", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 1200, "y": 400, "z": 12}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 850}}, + {"part_id": "DF", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 588, "y": 12, "z": 100}}, + {"part_id": "DIV", "name": "两个抽屉之间竖向中隔板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 400, "z": 153}}, + ], + } + ) + reconciled = self.engine._apply_topology_geometry_contract(analysis) + divider = next(item for item in reconciled["components"] if "抽屉之间" in item["name"]) + self.assertEqual(100.0, divider["dimensions_mm"]["z"]) + divider_instance = next(item for item in self.engine._component_instances(reconciled) if "抽屉之间" in item["part_name"]) + self.assertLessEqual(divider_instance["assembled_position_mm"]["z"] + 100.0, 850.0) + + def test_v65_reanalysis_restores_segmented_side_profile_without_radius_trim_duplicates(self): + base = self.engine._normalize_analysis( + { + "product_name": "开放书架", "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 398, "y": 200, "z": 12}}, + {"part_id": "BOT", "name": "底板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 300, "z": 12}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 300, "z": 1588}}, + {"part_id": "SHELF", "name": "层板", "quantity": 5, "dimensions_mm": {"x": 398, "y": 200, "z": 12}}, + ], + } + ) + previous = self.engine._apply_user_structure_overrides( + base, + "上部共5块层板,每块层板398×200×12mm;左右侧板每侧分为3段," + "分段高度按480、554、554mm;下段深300mm,中上段深200mm,外轮廓R30。", + ) + refreshed = self.engine._normalize_analysis( + { + "product_name": "开放书架", "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "components": [ + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 300, "z": 1600}}, + {"part_id": "TRIM", "name": "圆角装饰条", "quantity": 4, "dimensions_mm": {"x": 12, "y": 12, "z": 30}}, + {"part_id": "SHELF", "name": "层板", "quantity": 5, "dimensions_mm": {"x": 398, "y": 200, "z": 12}}, + ], + } + ) + restored = self.engine._restore_confirmed_topology_after_reanalysis(previous, refreshed) + side_instances = [ + item for item in self.engine._component_instances(restored) + if item.get("part_name", "").startswith(("左侧板", "右侧板")) + ] + self.assertEqual(6, len(side_instances)) + self.assertTrue( + self.engine._ai_cad_issue_is_contradicted( + restored, + { + "code": "segmented_side_panel_mismatch", + "component": "左侧板/右侧板", + "description": "尺寸图有模块接缝,但CAD使用一整块连续侧板", + "evidence": "侧板应为三段式结构", + }, + ) + ) + self.assertFalse(any("装饰条" in item.get("name", "") for item in restored["components"])) + lower_x = sorted(item["assembled_position_mm"]["x"] for item in side_instances if item["part_name"].endswith("下段)")) + upper_x = sorted(item["assembled_position_mm"]["x"] for item in side_instances if item["part_name"].endswith("上段)")) + self.assertEqual([0.0, 468.0], lower_x) + self.assertEqual([29.0, 439.0], upper_x) + + def test_v67_natural_chinese_side_segments_and_bottom_drawers_are_unambiguous(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "开放书架", "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 398, "y": 200, "z": 12}}, + {"part_id": "SIDE-L", "name": "左侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 300, "z": 1600}}, + {"part_id": "SIDE-R", "name": "右侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 300, "z": 1600}}, + {"part_id": "DF", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 376, "y": 12, "z": 168}}, + {"part_id": "SHELF", "name": "层板", "quantity": 5, "dimensions_mm": {"x": 398, "y": 200, "z": 12}}, + ], + } + ) + analysis["user_topology_constraints"] = { + "lower_section_height_mm": 480, + "shelf_dimensions_mm": {"x": 398, "y": 200, "z": 12}, + "side_profile": { + "type": "stepped_front_radius", "lower_height_mm": 480, + "lower_depth_mm": 300, "upper_depth_mm": 200, + }, + } + updated = self.engine._apply_user_structure_overrides( + analysis, + "开放书架上部5块层板,底部仅2个上下抽屉,左右侧板每侧分3段且外轮廓有R30圆角。", + ) + contract = updated["user_topology_constraints"] + self.assertEqual("bottom", contract["drawer_zone"]) + self.assertEqual(3, contract["side_segments_per_side"]) + self.assertEqual([480.0, 554.0, 554.0], contract["side_segment_heights_mm"]) + side_instances = [ + item for item in self.engine._component_instances(updated) + if item.get("part_name", "").startswith(("左侧板", "右侧板")) + ] + self.assertEqual(6, len(side_instances)) + + def test_v68_upload_turn_estimation_consent_builds_labelled_shell_before_questions(self): + model_analysis = { + "product_name": "开放书架", "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "dimension_evidence": { + axis: {"value": value, "source": "尺寸图明确标注", "confidence": 0.96} + for axis, value in {"x": 480, "y": 300, "z": 1600}.items() + }, + "has_doors": False, "drawer_count": 2, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 456, "y": 200, "z": 12}, "confidence": 0.95}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 300, "z": 1588}, "confidence": 0.95}, + {"part_id": "SHELF", "name": "层板", "quantity": 5, "dimensions_mm": {"x": 456, "y": 200, "z": 12}, "confidence": 0.95}, + {"part_id": "DF", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 456, "y": 12, "z": 168}, "confidence": 0.90}, + ], + } + with mock.patch.object(self.engine, "_request_model_json", return_value=model_analysis): + analysis = self.engine._vision_analyze( + [], + "请根据多视角图片识别;未标注内部尺寸允许按CAD装配合理值预估。", + ) + self.assertTrue(analysis["estimation_consent"]["approved"]) + self.assertTrue(any(item.get("name") == "柜体底板" for item in analysis["components"])) + question_ids = {item.get("id") for item in analysis.get("structure_questions") or []} + self.assertNotIn("cabinet_carcass", question_ids) + self.assertNotIn("cad_estimation_consent", question_ids) + + def test_v69_high_confidence_cad_review_repairs_topology_without_full_reanalysis(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "开放书架", "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "has_doors": True, "door_mechanism": "hinged", "drawer_count": 2, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 398, "y": 200, "z": 12}}, + {"part_id": "BOT", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 300, "z": 12}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 300, "z": 1588}}, + {"part_id": "DOOR", "name": "柜门", "quantity": 2, "dimensions_mm": {"x": 376, "y": 12, "z": 720}}, + {"part_id": "DF", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 376, "y": 12, "z": 168}}, + {"part_id": "SHELF", "name": "层板", "quantity": 4, "dimensions_mm": {"x": 398, "y": 200, "z": 12}}, + ], + } + ) + analysis["cad_review"] = { + "reviewed_at": "2026-09-07T00:00:00+00:00", + "view_findings": [ + { + "asset_id": index, "complete_product_front_visible": index == 1, + "count_confidence": 0.95, + "visible_counts": {"drawers": 2, "doors": 0, "shelves": 5}, + "drawer_layout": "vertical", "shelf_layout": "upper_open", + "has_back_panel": False, + "side_panel_segments_per_side": 3 if index == 4 else 1, + "side_panel_segment_heights_mm": [480, 554, 554] if index == 4 else [], + "side_panel_segmentation_confidence": 0.95 if index == 4 else 0.0, + "edge_profile_radius_mm": 30 if index == 4 else 0, + "edge_profile_confidence": 0.95 if index == 4 else 0.0, + "evidence": "原图清晰显示2个抽屉、5块层板、无柜门" if index < 4 else "尺寸图显示每侧三段且R30", + } + for index in range(1, 5) + ], + } + patched, audit = self.engine._apply_high_confidence_cad_review_patch(analysis) + contract = patched["user_topology_constraints"] + self.assertTrue(audit) + self.assertEqual("AI多视角与CAD联合复核", contract["source"]) + self.assertEqual({"doors": 0, "drawers": 2, "shelves": 5}, contract["expected_counts"]) + self.assertEqual("vertical", contract["drawer_layout"]) + self.assertEqual("upper_open", contract["shelf_layout"]) + self.assertEqual(3, contract["side_segments_per_side"]) + self.assertEqual(30.0, contract["edge_profile_radius_mm"]) + self.assertEqual(0, self.engine._door_panel_count(patched["components"])) + self.assertEqual(2, self.engine._drawer_front_count(patched["components"])) + self.assertEqual(5, sum( + max(1, int(item.get("quantity") or 1)) + for item in patched["components"] if self.engine._is_shelf_panel(item) + )) + side_instances = [ + item for item in self.engine._component_instances(patched) + if item.get("part_name", "").startswith(("左侧板", "右侧板")) + ] + self.assertEqual(6, len(side_instances)) + + def test_v69_annotated_perspective_is_ai_adjudicated_as_third_view(self): + assets = [ + { + "asset_id": index, "filename": f"neutral-{index}.png", "role": "unclassified", + "validation_status": "pending", "validation": {}, "kind": "image_base64", + "value": "data:image/png;base64,AA==", + } + for index in range(1, 5) + ] + broad_result = { + "same_product": True, "has_doors": False, + "assets": [ + {"asset_id": 1, "detected_role": "front", "usable": True, "issues": [], "confidence": 0.95, "view_evidence": "完整正面"}, + {"asset_id": 2, "detected_role": "side", "usable": True, "issues": [], "confidence": 0.92, "view_evidence": "完整侧面"}, + {"asset_id": 3, "detected_role": "dimension", "usable": True, "issues": [], "confidence": 0.90, "view_evidence": "带标注轴测图"}, + {"asset_id": 4, "detected_role": "dimension", "usable": True, "issues": [], "confidence": 0.93, "view_evidence": "详细尺寸轴测图"}, + ], + } + adjudication = { + "qualifies": True, "asset_id": 3, "confidence": 0.96, + "evidence": "轴测图同时显示正面、侧板和前后深度", + } + with mock.patch.object(self.engine, "_request_model_json", side_effect=[broad_result, adjudication]), mock.patch.object( + self.engine, "_asset_review_data_url", return_value="data:image/png;base64,AA==" + ): + result = self.engine._validate_assets_multimodal("missing-project-is-safe", assets) + self.assertTrue(result["ok"]) + self.assertEqual([], result["missing_roles"]) + accepted = {item["detected_role"]: item for item in result["assets"] if item["usable"]} + self.assertEqual({"front", "side", "open_or_third", "dimension"}, set(accepted)) + self.assertEqual(3, accepted["open_or_third"]["asset_id"]) + self.assertIn("前后深度", accepted["open_or_third"]["view_evidence"]) + + def test_v70_ai_self_repair_command_is_not_treated_as_user_topology_fact(self): + self.assertTrue( + self.engine._message_requests_ai_cad_self_repair( + "请由AI根据原始视角和CAD复核问题,自行定位漏件、多件并修正后重新生成。" + ) + ) + self.assertFalse( + self.engine._message_requests_ai_cad_self_repair( + "请AI修正为无柜门、2个上下抽屉,左右侧板每侧分3段。" + ) + ) + + def test_v72_dimension_semantics_repair_swapped_overall_width_and_depth_only(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "开放书架", + "product_type": "书架", + "overall_dimensions_mm": {"x": 300, "y": 480, "z": 1600}, + "dimension_evidence": { + "x": {"value": 300, "source": "尺寸图整体深", "confidence": 0.98}, + "y": {"value": 480, "source": "尺寸图整体宽", "confidence": 0.98}, + "z": {"value": 1600, "source": "尺寸图整体高", "confidence": 0.99}, + }, + "has_doors": False, + "drawer_count": 0, + "components": [ + { + "part_id": "TOP", + "name": "顶板", + "quantity": 1, + "dimensions_mm": {"x": 480, "y": 300, "z": 12}, + } + ], + } + ) + self.assertEqual({"x": 480.0, "y": 300.0, "z": 1600.0}, analysis["overall_dimensions_mm"]) + self.assertEqual("尺寸图整体宽", analysis["dimension_evidence"]["x"]["source"]) + self.assertEqual("尺寸图整体深", analysis["dimension_evidence"]["y"]["source"]) + self.assertEqual({"x": 480.0, "y": 300.0, "z": 12.0}, analysis["components"][0]["dimensions_mm"]) + self.assertEqual("product_semantic_axes_x_width_y_depth", analysis["dimension_axis_corrections"][0]["rule"]) + + def test_v72_high_confidence_multiview_no_back_does_not_ask_user(self): + base = { + "product_name": "开放边柜", + "product_type": "柜类家具", + "overall_dimensions_mm": {"x": 900, "y": 400, "z": 850}, + "has_doors": False, + "has_back_panel": False, + "back_panel_evidence": "正面、侧面与第三视角均可见开放后沿,没有背板遮挡", + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 900, "y": 400, "z": 12}}, + {"part_id": "BOTTOM", "name": "柜体底板", "quantity": 1, "dimensions_mm": {"x": 876, "y": 400, "z": 12}}, + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 400, "z": 826}}, + ], + } + high = self.engine._normalize_analysis({**base, "back_panel_confidence": 0.93}) + self.assertNotIn("back_panel", {item["id"] for item in high["structure_questions"]}) + low = self.engine._normalize_analysis({**base, "back_panel_confidence": 0.50}) + self.assertIn("back_panel", {item["id"] for item in low["structure_questions"]}) + + def test_v73_malformed_json_salvage_keeps_axis_evidence_for_semantic_repair(self): + raw = ( + '{"product_name":"开放书架","product_type":"书架",' + '"overall_dimensions_mm":{"x":300,"y":480,"z":1600},' + '"dimension_evidence":{' + '"x":{"value":300,"source":"尺寸图整体深","confidence":0.95},' + '"y":{"value":480,"source":"尺寸图整体宽","confidence":0.95},' + '"z":{"value":1600,"source":"尺寸图整体高","confidence":0.95}},' + '"has_doors":false,"components":[' + '{"part_id":"TOP","name":"顶板","quantity":1,' + '"dimensions_mm":{"x":480,"y":300,"z":12}}]' + ) + salvaged = self.engine._salvage_model_json(raw) + normalized = self.engine._normalize_analysis(salvaged) + self.assertEqual({"x": 480.0, "y": 300.0, "z": 1600.0}, normalized["overall_dimensions_mm"]) + self.assertEqual("尺寸图整体宽", normalized["dimension_evidence"]["x"]["source"]) + self.assertTrue(normalized["dimension_axis_corrections"]) + + def test_v74_initial_multiview_segmentation_becomes_six_manufacturing_parts(self): + analysis = self.engine._normalize_analysis( + { + "product_name": "模块书架", + "product_type": "书架", + "overall_dimensions_mm": {"x": 480, "y": 300, "z": 1600}, + "has_doors": False, + "drawer_count": 0, + "side_panel_segmentation": { + "segments_per_side": 3, + "segment_heights_mm": [48, 55.4, 55.4], + "segment_depths_mm": [300, 200, 200], + "evidence": "尺寸图显示两条模块接缝和48/55.4/55.4cm高度链", + "confidence": 0.96, + }, + "components": [ + {"part_id": "TOP", "name": "顶板", "quantity": 1, "dimensions_mm": {"x": 480, "y": 200, "z": 12}}, + {"part_id": "SIDE-L", "name": "左侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 300, "z": 1588}}, + {"part_id": "SIDE-R", "name": "右侧板", "quantity": 1, "dimensions_mm": {"x": 12, "y": 300, "z": 1588}}, + ], + } + ) + sides = [ + item for item in analysis["components"] + if "侧板" in item["name"] and "抽屉" not in item["name"] + ] + self.assertEqual(6, len(sides)) + self.assertEqual({0, 1, 2}, {item["module_segment_index"] for item in sides}) + self.assertEqual([480.0, 554.0, 554.0], analysis["user_topology_constraints"]["side_segment_heights_mm"]) + self.assertEqual(300.0, analysis["user_topology_constraints"]["side_segment_lower_depth_mm"]) + self.assertEqual(200.0, analysis["user_topology_constraints"]["side_segment_upper_depth_mm"]) + + def test_v75_cad_reanalysis_cannot_undo_semantic_axis_repair(self): + previous = self.engine._normalize_analysis( + { + "product_name": "模块书架", + "product_type": "书架", + "overall_dimensions_mm": {"x": 300, "y": 480, "z": 1600}, + "dimension_evidence": { + "x": {"value": 300, "source": "尺寸图整体深", "confidence": 0.95}, + "y": {"value": 480, "source": "尺寸图整体宽", "confidence": 0.95}, + "z": {"value": 1600, "source": "尺寸图整体高", "confidence": 0.95}, + }, + "components": [ + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 300, "z": 1600}}, + ], + } + ) + previous["user_topology_constraints"] = {"expected_counts": {"doors": 0}} + refreshed = self.engine._normalize_analysis( + { + "product_name": "模块书架", + "product_type": "书架", + "overall_dimensions_mm": {"x": 300, "y": 480, "z": 1600}, + "components": [ + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 480, "z": 1600}}, + ], + } + ) + restored = self.engine._restore_confirmed_topology_after_reanalysis(previous, refreshed) + self.assertEqual({"x": 480.0, "y": 300.0, "z": 1600.0}, restored["overall_dimensions_mm"]) + self.assertEqual(1, len(restored["dimension_axis_corrections"])) + + def test_v77_cad_reviewer_xy_swap_requires_axis_evidence(self): + analysis = { + "overall_dimensions_mm": {"x": 480.0, "y": 300.0, "z": 1600.0}, + "drawer_count": 0, + "door_count": 0, + "components": [ + {"part_id": "SIDE", "name": "侧板", "quantity": 2, "dimensions_mm": {"x": 12, "y": 300, "z": 1600}}, + ], + } + raw = { + "view_findings": [ + { + "asset_id": 1, + "declared_role": "dimension", + "observed_role": "dimension", + "confidence": 0.95, + "dimension_confidence": 0.95, + "overall_dimensions_mm": {"x": 300, "y": 480, "z": 1600}, + "evidence": "复核模型将图像轴当成产品语义轴", + } + ] + } + issues = self.engine._cad_review_view_issues( + analysis, + raw, + [{"asset_id": 1, "role": "dimension"}], + ) + self.assertEqual({"source_overall_x_mismatch", "source_overall_y_mismatch"}, {item["code"] for item in issues}) + raw["view_findings"][0]["dimension_evidence"] = { + "x": {"source": "尺寸图整体深"}, "y": {"source": "尺寸图整体宽"}, + } + issues = self.engine._cad_review_view_issues(analysis, raw, [{"asset_id": 1, "role": "dimension"}]) + self.assertFalse(any(str(item.get("code") or "").startswith("source_overall_") for item in issues)) + + def test_v77_arrow_transcription_handles_no_table_units_and_local_scope(self): + analysis = {"overall_dimensions_mm": {"x": 700, "y": 350, "z": 1200}, "components": [], + "dimension_evidence": {"x": {"confidence": 0.99, "source": "首次读图"}}} + schedule = {"table_present": False, "missing_overall_axes": ["x"], "overall_dimension_labels": [ + {"axis": "z", "value": 42, "unit": "cm", "scope": "module", "text": "42", "location": "下模块上下沿", "confidence": 0.99}, + {"axis": "z", "value": 150, "unit": "cm", "scope": "overall", "text": "150", "location": "成品底部至顶部", "confidence": 0.96}, + {"axis": "y", "value": 32, "unit": "cm", "scope": "overall", "text": "32", "location": "成品前沿到后沿", "confidence": 0.94}, + ]} + result = self.engine._apply_dimension_schedule(analysis, schedule) + self.assertEqual({"x": 700, "y": 320, "z": 1500}, result["overall_dimensions_mm"]) + self.assertLessEqual(result["dimension_evidence"]["x"]["confidence"], 0.55) + self.assertEqual(1500, result["dimension_evidence"]["z"]["value"]) + self.assertEqual("cm", result["dimension_evidence"]["z"]["source_unit"]) + self.assertEqual(1, len(result["source_dimension_audit"]["rejected"])) + + def test_v77_naked_schedule_triple_cannot_overwrite_dimensions(self): + analysis = {"overall_dimensions_mm": {"x": 680, "y": 320, "z": 1500}, "components": []} + result = self.engine._apply_dimension_schedule(analysis, { + "table_present": True, "confidence": 0.99, "rows": [], + "overall_dimensions_mm": {"x": 320, "y": 680, "z": 1500}, + }) + self.assertEqual(analysis["overall_dimensions_mm"], result["overall_dimensions_mm"]) + + def test_v77_dimension_values_and_evidence_restore_without_topology(self): + previous = {"overall_dimensions_mm": {"x": 680, "y": 320, "z": 1500}, "dimension_evidence": { + "x": {"value": 680, "source": "原图箭头", "kind": "source_arrow_label", "confidence": 0.94}, + }} + fresh = {"overall_dimensions_mm": {"x": 320, "y": 330, "z": 1510}, "dimension_evidence": { + "x": {"value": 320, "source": "估算", "confidence": 0.5}, + "y": {"value": 330, "source": "新图直接标注", "kind": "source_arrow_label", "confidence": 0.96}, + }} + result = self.engine._restore_confirmed_topology_after_reanalysis(previous, fresh) + self.assertEqual(680, result["overall_dimensions_mm"]["x"]) + self.assertEqual(680, result["dimension_evidence"]["x"]["value"]) + self.assertEqual(330, result["overall_dimensions_mm"]["y"]) + + def test_v77_cad_render_and_duplicate_photo_do_not_vote_as_independent_sources(self): + analysis = {"components": [], "cad_review": {"view_findings": [ + {"asset_id": 1, "count_confidence": 0.85, "visible_counts": {"doors": 4}}, + {"asset_id": 1, "count_confidence": 0.85, "visible_counts": {"doors": 4}}, + {"asset_id": 0, "declared_role": "cad", "count_confidence": 0.99, "visible_counts": {"doors": 4}}, + ]}} + _, audit = self.engine._apply_high_confidence_cad_review_patch(analysis) + self.assertFalse(audit) + + def test_v77_ai_topology_does_not_suppress_later_source_disagreement(self): + analysis = {"components": [], "door_count": 2, "drawer_count": 0, + "user_topology_constraints": {"source": "AI多视角与CAD联合复核", "expected_counts": {"doors": 2}}} + raw = {"view_findings": [{"asset_id": 1, "declared_role": "front", "complete_product_front_visible": True, + "count_confidence": 0.95, "visible_counts": {"doors": 0}}]} + issues = self.engine._cad_review_view_issues(analysis, raw, [{"asset_id": 1, "role": "front"}]) + self.assertIn("source_doors_count_mismatch", {item["code"] for item in issues}) + + def test_v77_vision_prompt_has_no_benchmark_dimension_answers(self): + prompt = json.dumps(self.engine._vision_content([], ""), ensure_ascii=False) + for leaked in ("[480,554,554]", "[300,200,200]", '"leaf_height":540'): + self.assertNotIn(leaked, prompt) + + def test_v77_normalization_does_not_read_depth_reason_as_board_thickness(self): + parts = [{"part_id": "TOP", "name": "顶板", "quantity": 1, + "dimensions_mm": {"x": 1400, "y": 800, "z": 20}, + "dimension_evidence": { + "x": {"source": "尺寸表转录宽", "confidence": 0.95}, + "y": {"source": "尺寸表转录深", "confidence": 0.95}, + "z": {"source": "尺寸表转录厚", "confidence": 0.95}, + }}] + bounds = {"x": 1200, "y": 500, "z": 750} + normalized = self.engine._canonicalize_component_geometry(parts, bounds, has_doors=False) + expected = json.loads(json.dumps(normalized)) + again = self.engine._canonicalize_component_geometry(normalized, bounds, has_doors=False) + self.assertEqual(expected[0]["dimensions_mm"], again[0]["dimensions_mm"]) + self.assertEqual(20, again[0]["dimensions_mm"]["z"]) + self.assertEqual("depth", again[0]["dimension_evidence"]["y"]["dimension_kind"]) + + def test_v77_conflicting_present_and_missing_axis_is_not_accepted(self): + original = {"overall_dimensions_mm": {"x": 600}, "dimension_evidence": {"x": {"confidence": 0.95}}} + result = self.engine._apply_source_dimension_labels(original, { + "missing_overall_axes": ["x"], "overall_dimension_labels": [{ + "axis": "x", "scope": "overall", "value": 30, "unit": "cm", + "text": "30cm", "location": "前沿", "confidence": 0.99, + }], + }) + self.assertEqual(600, result["overall_dimensions_mm"]["x"]) + self.assertLessEqual(result["dimension_evidence"]["x"]["confidence"], 0.55) + self.assertIn("x", result["source_dimension_audit"]["conflicts"]) + + def test_v79_focused_dimension_reader_uses_dimension_plus_axis_references_not_bom_guesses(self): + assets = [{"asset_id": index, "role": role, "value": f"image-{index}"} + for index, role in enumerate(("dimension", "front", "side", "open_or_third"), 1)] + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_request_model_json", return_value={"overall_dimension_labels": [], "missing_overall_axes": []}) as call: + self.engine._read_source_dimension_labels(assets[0], assets) + self.assertEqual(2, call.call_count) + content = call.call_args_list[0].args[0]["messages"][0]["content"] + self.assertEqual({"image-1", "image-2", "image-3"}, + {item["image_url"]["url"] for item in content if item["type"] == "image_url"}) + compact = call.call_args_list[1].args[0]["messages"][0]["content"] + self.assertEqual({"image-1", "image-2"}, + {item["image_url"]["url"] for item in compact if item["type"] == "image_url"}) + for request in call.call_args_list: + self.assertEqual(1, request.kwargs["max_attempts"]) + self.assertFalse(request.args[0]["enable_thinking"]) + + def test_v79_dimension_consensus_recovers_axis_omitted_by_first_read(self): + assets = [{"role": "dimension", "value": "dimension"}, {"role": "front", "value": "front"}] + first = {"overall_dimension_labels": [ + {"axis": "z", "value": 160, "unit": "cm", "scope": "overall", "confidence": 0.95}, + ], "missing_overall_axes": ["x", "y"]} + second = {"overall_dimension_labels": [ + {"axis": "x", "value": 39.8, "unit": "cm", "scope": "overall", "confidence": 0.92}, + {"axis": "y", "value": 30, "unit": "cm", "scope": "overall", "confidence": 0.94}, + ], "missing_overall_axes": ["z"]} + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_request_model_json", side_effect=[first, second]): + result = self.engine._read_source_dimension_labels(assets[0], assets) + self.assertEqual([], result["missing_overall_axes"]) + self.assertEqual({"x", "y", "z"}, {row["axis"] for row in result["overall_dimension_labels"]}) + self.assertEqual(2, result["independent_read_count"]) + + def test_v79_dimension_second_read_failure_preserves_first_source_evidence(self): + assets = [{"role": "dimension", "value": "dimension"}, {"role": "front", "value": "front"}] + first = {"overall_dimension_labels": [ + {"axis": "z", "value": 160, "unit": "cm", "scope": "overall", "confidence": 0.95}, + ], "missing_overall_axes": ["x", "y"]} + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_request_model_json", side_effect=[first, self.module.SolidWoodCostError("timeout")]): + result = self.engine._read_source_dimension_labels(assets[0], assets) + self.assertEqual(1, result["independent_read_count"]) + self.assertIn("timeout", result["independent_reread_error"]) + self.assertEqual("z", result["overall_dimension_labels"][0]["axis"]) + + def test_v80_missing_axis_uses_generic_source_quadrants_not_a_hardcoded_answer(self): + assets = [{"role": "dimension", "value": "dimension"}, {"role": "front", "value": "front"}] + first = {"overall_dimension_labels": [ + {"axis": "z", "value": 160, "unit": "cm", "scope": "overall", "confidence": 0.95}, + ], "missing_overall_axes": ["x", "y"]} + second = {"overall_dimension_labels": [ + {"axis": "Y", "value": 30, "unit": "cm", "scope": "overall", "confidence": 0.94}, + ], "missing_overall_axes": ["x", "z"]} + focused = {"overall_dimension_labels": [ + {"axis": "X", "value": 39.8, "unit": "cm", "scope": "overall", "confidence": 0.92}, + ], "missing_overall_axes": []} + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_dimension_focus_sheet_data_url", return_value="data:image/jpeg;base64,AA=="), \ + mock.patch.object(self.engine, "_request_model_json", side_effect=[first, second, focused]) as request: + result = self.engine._read_source_dimension_labels(assets[0], assets) + self.assertEqual(3, request.call_count) + self.assertEqual([], result["missing_overall_axes"]) + self.assertTrue(result["focused_missing_axis_read"]) + self.assertEqual({"x", "y", "z"}, {row["axis"] for row in result["overall_dimension_labels"]}) + focused_prompt = request.call_args_list[2].args[0]["messages"][0]["content"][0]["text"] + self.assertNotIn("39.8", focused_prompt) + + def test_v81_red_annotation_focus_is_pixel_only_and_contains_no_ocr_answer(self): + image = self.module.PILImage.new("RGB", (480, 240), "white") + draw = self.module.PILImageDraw.Draw(image) + draw.line((40, 120, 440, 120), fill=(220, 20, 20), width=4) + draw.text((190, 80), "12.34", fill=(220, 20, 20)) + buffer = self.module.io.BytesIO() + image.save(buffer, format="PNG") + item = { + "kind": "image_base64", + "value": "data:image/png;base64," + self.module.base64.b64encode(buffer.getvalue()).decode("ascii"), + } + data_url = self.engine._dimension_annotation_focus_data_url(item) + self.assertTrue(data_url.startswith("data:image/png;base64,")) + self.assertNotIn("12.34", data_url) + mask_raw = self.module.base64.b64decode(data_url.split(",", 1)[1]) + with self.module.PILImage.open(self.module.io.BytesIO(mask_raw)) as mask: + self.assertEqual((0, 255), mask.convert("L").getextrema()) + + def test_v82_annotation_reread_recovers_axis_that_quadrant_reader_omits(self): + assets = [{"role": "dimension", "value": "dimension"}, {"role": "front", "value": "front"}] + first = {"overall_dimension_labels": [ + {"axis": "z", "value": 160, "unit": "cm", "scope": "overall", "confidence": 0.95}, + ], "missing_overall_axes": ["x", "y"]} + second = {"overall_dimension_labels": [ + {"axis": "y", "value": 30, "unit": "cm", "scope": "overall", "confidence": 0.94}, + ], "missing_overall_axes": ["x", "z"]} + focused = {"overall_dimension_labels": [], "missing_overall_axes": ["x"]} + annotation = {"overall_dimension_labels": [ + {"axis": "x", "value": 42, "unit": "cm", "scope": "overall", "confidence": 0.91}, + {"axis": "z", "value": 51, "unit": "cm", "scope": "module", "confidence": 0.90}, + ], "missing_overall_axes": []} + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_dimension_focus_sheet_data_url", return_value="data:image/jpeg;base64,AA=="), \ + mock.patch.object(self.engine, "_dimension_annotation_focus_data_url", return_value="data:image/png;base64,AA=="), \ + mock.patch.object(self.engine, "_request_model_json", side_effect=[first, second, focused, annotation]) as request: + result = self.engine._read_source_dimension_labels(assets[0], assets) + self.assertEqual(4, request.call_count) + self.assertEqual([], result["missing_overall_axes"]) + self.assertTrue(result["annotation_reread_used"]) + self.assertEqual({"x", "y", "z"}, { + row["axis"] for row in result["overall_dimension_labels"] if row.get("scope") == "overall" + }) + self.assertTrue(any(row.get("scope") == "module" for row in result["overall_dimension_labels"])) + + def test_v83_endpoint_adjudication_promotes_only_an_existing_numeric_label(self): + assets = [{"role": "dimension", "value": "dimension"}, {"role": "front", "value": "front"}] + first = {"overall_dimension_labels": [ + {"axis": "z", "value": 160, "unit": "cm", "scope": "overall", "confidence": 0.98}, + ], "missing_overall_axes": ["x", "y"]} + second = {"overall_dimension_labels": [ + {"axis": "y", "value": 30, "unit": "cm", "scope": "overall", "confidence": 0.96}, + ], "missing_overall_axes": ["x"]} + focused = {"overall_dimension_labels": [], "missing_overall_axes": ["x"]} + annotation = {"overall_dimension_labels": [ + {"axis": "x", "value": 39.8, "unit": "cm", "scope": "unknown", "confidence": 0.95}, + {"axis": "x", "value": 37.6, "unit": "cm", "scope": "part", "confidence": 0.96}, + ], "missing_overall_axes": ["x"]} + adjudicated = {"decisions": [{ + "candidate_index": 2, + "axis": "x", + "scope": "overall", + "confidence": 0.97, + "endpoint_evidence": "左端落在成品左外沿,右端落在成品右外沿", + }], "missing_axes": []} + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_dimension_focus_sheet_data_url", return_value="data:image/jpeg;base64,AA=="), \ + mock.patch.object(self.engine, "_dimension_annotation_focus_data_url", return_value="data:image/png;base64,AA=="), \ + mock.patch.object( + self.engine, "_request_model_json", + side_effect=[first, second, focused, annotation, adjudicated], + ) as request: + result = self.engine._read_source_dimension_labels(assets[0], assets) + self.assertEqual(5, request.call_count) + self.assertEqual([], result["missing_overall_axes"]) + self.assertTrue(result["scope_adjudication_used"]) + promoted = result["overall_dimension_labels"][2] + self.assertEqual(39.8, promoted["value"]) + self.assertEqual("overall", promoted["scope"]) + self.assertEqual("source_pixel_endpoint_adjudication", promoted["scope_source"]) + self.assertEqual("part", result["overall_dimension_labels"][3]["scope"]) + + def test_v77_extra_arrow_audit_failure_preserves_valid_part_table(self): + table = {"table_present": True, "confidence": 0.95, "rows": [{"role": "top"}], "hardware": []} + with mock.patch.object(self.engine, "_asset_review_data_url", return_value="test-image"), \ + mock.patch.object(self.engine, "_request_model_json", return_value=table), \ + mock.patch.object(self.engine, "_read_source_dimension_labels", side_effect=self.module.SolidWoodCostError("test failure")): + result = self.engine._extract_dimension_schedule({}) + self.assertEqual([{"role": "top"}], result["rows"]) + self.assertEqual("retry_in_cad_review", result["source_dimension_audit_status"]) + + + def test_v78_ai_vertical_drawers_keep_width_thickness_and_cad_layout(self): + raw = { + "product_name": "三抽柜", "product_type": "柜", "has_doors": False, + "overall_dimensions_mm": {"x": 720, "y": 510, "z": 1000}, + "drawer_count": 3, "drawer_layout": "vertical", "drawer_layout_confidence": 0.96, + "drawer_layout_evidence": "正视图三个独立抽面上下叠放,左右边界一致", + "components": [ + {"part_id": "FRONT", "name": "抽屉面板", "quantity": 3, + "dimensions_mm": {"x": 680, "y": 18, "z": 210}}, + {"part_id": "BACK", "name": "背板", "quantity": 1, + "dimensions_mm": {"x": 680, "y": 4, "z": 940}}, + ], + } + result = self.engine._normalize_analysis(raw) + front = next(item for item in result["components"] if self.engine._is_drawer_front(item)) + self.assertEqual({"x": 680, "y": 18, "z": 210}, front["dimensions_mm"]) + self.assertEqual("vertical", self.engine._drawer_layout(result)) + self.assertFalse(str((result.get("user_topology_constraints") or {}).get("source") or "").startswith("用户")) + origins = [self.engine._assembly_origin_for_instance(result, front, i) for i in range(3)] + self.assertEqual(1, len({origin["x"] for origin in origins})) + self.assertEqual(3, len({origin["z"] for origin in origins})) + for role in ("left", "right", "back", "bottom"): + self.assertEqual(3, sum(item["quantity"] for item in result["components"] if self.engine._drawer_box_role(item) == role)) + + def test_v78_unknown_drawer_layout_never_divides_source_width(self): + front = {"part_id": "F", "name": "抽屉面板", "quantity": 3, + "dimensions_mm": {"x": 630, "y": 15, "z": 380}} + result = self.engine._ensure_complete_drawer_boxes([front], {"x": 680, "y": 480, "z": 1300}, 3) + self.assertEqual(front["dimensions_mm"], result[0]["dimensions_mm"]) + self.assertEqual("unknown", self.engine._drawer_layout({"drawer_layout": "vertical", "drawer_layout_confidence": 1.0})) + + def test_v78_cabinet_support_is_not_a_drawer_bottom(self): + components = [ + {"part_id": "F", "name": "抽屉面板", "quantity": 2, "dimensions_mm": {"x": 460, "y": 12, "z": 140}}, + {"part_id": "SUP", "name": "抽屉底板", "quantity": 1, "dimensions_mm": {"x": 920, "y": 350, "z": 12}, + "component_role": "drawer_support", "assembly_group": "carcass", "structure_evidence": "开门图中固定横隔板跨越两个抽屉下方"}, + {"part_id": "DB", "name": "抽屉底板", "quantity": 2, "dimensions_mm": {"x": 436, "y": 320, "z": 4}}, + ] + result = self.engine._ensure_complete_drawer_boxes(components, {"x": 960, "y": 380, "z": 900}, 2, drawer_layout="horizontal") + bottoms = [item for item in result if self.engine._drawer_box_role(item) == "bottom"] + self.assertEqual(2, sum(item["quantity"] for item in bottoms)) + self.assertEqual(["DB"], [item["part_id"] for item in bottoms]) + support = next(item for item in result if item["part_id"] == "SUP") + self.assertEqual("抽屉底板", support["source_component_name"]) + self.assertEqual("主体结构", support["material_group"]) + self.assertEqual(920, support["dimensions_mm"]["x"]) + + def test_v78_wide_single_drawer_bottom_not_reclassified_without_source_evidence(self): + bottom = {"part_id": "B", "name": "抽屉底板", "quantity": 1, + "dimensions_mm": {"x": 940, "y": 430, "z": 12}} + self.assertEqual([bottom], self.engine._normalize_drawer_support_roles([bottom])) + self.assertEqual("bottom", self.engine._drawer_box_role(bottom)) + + def test_v78_generated_drawer_box_follows_source_front_not_global_cabinet_grid(self): + front = {"part_id": "F", "name": "抽屉面板", "quantity": 2, + "dimensions_mm": {"x": 550, "y": 15, "z": 155}, + "origins_mm": [{"x": 25, "y": 0, "z": 50}, {"x": 25, "y": 0, "z": 220}]} + overall = {"x": 600, "y": 400, "z": 1500} + components = self.engine._ensure_complete_drawer_boxes([front], overall, 2, drawer_layout="vertical") + analysis = {"overall_dimensions_mm": overall, "components": components, "drawer_count": 2, + "drawer_layout": "vertical", "drawer_layout_confidence": 0.96, + "drawer_layout_evidence": "原图显示下部两只抽屉上下排列"} + lefts = [part for part in components if self.engine._drawer_box_role(part) == "left"] + self.assertEqual([50, 220], [self.engine._assembly_origin_for_instance(analysis, part, 0)["z"] for part in lefts]) + self.assertEqual([25, 25], [self.engine._assembly_origin_for_instance(analysis, part, 0)["x"] for part in lefts]) + + def test_v78_first_pass_subtraction_operand_does_not_become_board_thickness(self): + components = [{"name": "搁板", "part_id": "S", "quantity": 3, + "dimensions_mm": {"x": 684, "y": 382, "z": 18}, + "dimension_evidence": { + "x": {"source": "反推(总宽720减去两侧板厚18*2)", "confidence": 0.55}, + "y": {"source": "反推(总深400减去背板厚18)", "confidence": 0.55}, + "z": {"source": "估算(常规板厚)", "confidence": 0.55}, + }}] + result = self.engine._canonicalize_component_geometry(components, {"x": 720, "y": 400, "z": 900}, has_doors=False) + self.assertEqual({"x": 684, "y": 382, "z": 18}, result[0]["dimensions_mm"]) + self.assertEqual("thickness", result[0]["dimension_evidence"]["z"]["dimension_kind"]) + + def test_v79_source_views_auto_accept_corroborated_drawers_and_side_seams(self): + raw = {"view_findings": [ + {"image_id": 1, "observed_role": "front", "complete_product_visible": True, + "side_edge_visibility": 0.9, "visible_counts": {"drawers": 2}, "drawer_layout": "vertical", + "side_segments_per_side": 3, "side_seam_z_ratios": [0.30, 0.65], + "side_seam_evidence": [ + {"ratio_from_bottom": 0.30, "physical_break_visible": True, "boundary_description": "下段侧板在此终止,上段重新起始"}, + {"ratio_from_bottom": 0.65, "physical_break_visible": True, "boundary_description": "中段侧板在此终止,上段重新起始"}, + ], + "confidence": 0.96, "evidence": "正面看见两个上下排列抽面和两条侧板接缝"}, + {"image_id": 4, "observed_role": "dimension", "complete_product_visible": True, + "side_edge_visibility": 0.95, "visible_counts": {"drawers": 2}, "drawer_layout": "vertical", + "side_segments_per_side": 3, "side_seam_z_ratios": [0.31, 0.64], + "side_seam_evidence": [ + {"ratio_from_bottom": 0.31, "physical_break_visible": True, "boundary_description": "尺寸轮廓下模块接缝"}, + {"ratio_from_bottom": 0.64, "physical_break_visible": True, "boundary_description": "尺寸轮廓上下白色模块接缝"}, + ], + "confidence": 0.93, "evidence": "尺寸图轮廓显示相同分段"}, + ]} + result = self.engine._adjudicate_source_structure_views(raw, {1, 2, 3, 4}) + self.assertEqual({"count": 2, "layout": "vertical"}, { + "count": result["accepted"]["drawers"]["count"], + "layout": result["accepted"]["drawers"]["layout"], + }) + self.assertEqual(3, result["accepted"]["side_panels"]["segments_per_side"]) + self.assertFalse(result["user_confirmation_required"]) + + def test_v79_conflicting_source_views_trigger_machine_reread_not_user_question(self): + raw = {"view_findings": [ + {"image_id": 1, "complete_product_visible": True, "side_edge_visibility": 0.9, + "visible_counts": {"drawers": 2}, "drawer_layout": "vertical", + "side_segments_per_side": 1, "confidence": 0.94, "evidence": "照片外沿似连续"}, + {"image_id": 4, "complete_product_visible": True, "side_edge_visibility": 0.95, + "visible_counts": {"drawers": 2}, "drawer_layout": "vertical", + "side_segments_per_side": 3, "side_seam_z_ratios": [0.30, 0.65], + "confidence": 0.93, "evidence": "尺寸图轮廓存在接缝"}, + ]} + result = self.engine._adjudicate_source_structure_views(raw, {1, 2, 3, 4}) + self.assertNotIn("side_panels", result["accepted"]) + self.assertIn("side_segments_per_side", {row["field"] for row in result["conflicts"]}) + self.assertTrue(result["machine_followups"]) + self.assertFalse(result["user_confirmation_required"]) + + def test_v80_side_segment_vote_without_physical_break_evidence_is_not_accepted(self): + raw = {"view_findings": [ + {"image_id": 2, "complete_product_visible": True, "side_edge_visibility": 0.95, + "visible_counts": {"drawers": 2}, "drawer_layout": "vertical", + "side_segments_per_side": 2, "side_seam_z_ratios": [0.60], + "confidence": 0.96, "evidence": "只按颜色变化判断一条接缝"}, + {"image_id": 4, "complete_product_visible": True, "side_edge_visibility": 0.95, + "visible_counts": {"drawers": 2}, "drawer_layout": "vertical", + "side_segments_per_side": 2, "side_seam_z_ratios": [0.60], + "confidence": 0.96, "evidence": "把整体尺寸文字当成接缝"}, + ]} + result = self.engine._adjudicate_source_structure_views(raw, {1, 2, 3, 4}) + self.assertNotIn("side_panels", result["accepted"]) + self.assertTrue(result["machine_followups"]) + + def test_v79_focused_structure_reread_uses_all_original_images_without_user_prompt(self): + assets = [{"role": role, "value": f"image-{index}"} + for index, role in enumerate(("front", "side", "open_or_third", "dimension"), 1)] + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_request_model_json", return_value={"view_findings": []}) as call: + self.engine._reread_source_structure_conflicts( + assets, {"machine_followups": ["对抽屉区域做局部放大并重新计数"]} + ) + content = call.call_args.args[0]["messages"][0]["content"] + self.assertEqual( + {f"image-{index}" for index in range(1, 5)}, + {item["image_url"]["url"] for item in content if item["type"] == "image_url"}, + ) + prompt = " ".join(item.get("text", "") for item in content if item["type"] == "text") + self.assertIn("不得询问用户", prompt) + + +if __name__ == "__main__": + unittest.main()