diff --git a/CHANGELOG b/CHANGELOG index 85854c2..34ce0b3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 23:30] TEST+FIX: v2.33 26 项功能落地性复查——综合冒烟 52/52 PASS + 23 页巡检 0 异常。新增 scripts/smoke_all.py(26 项逐项接口冒烟: 每项验证创建/查询/联动/校验拦截全链路)+frontend/scripts/verify-pages.mjs(23 页无头巡检收集 JS 异常)。复查发现并修复 6 处: ①order_format 校验缺 barcode 类型(条码块模板无法保存) ②workshop/scan 缺 selectinload(MissingGreenlet 500) ③pickout 仅匹配单库存项(改跨批次累计扣减) ④照片模式洞口位置未传 AI(rect 相对坐标→英文方位描述进提示词, 引导装窗位置; 附 mime 嗅探修正 jpeg/png) ⑤打印条码 svg ref 脱离 DOM 残留(isConnected 过滤) ⑥AI 推荐只打分前 80 套(改全量 254 防分类偏倚); 冒烟残留数据已清理 (Files: src/huamenchuang/api/ai_render.py, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/api/order_format.py, src/huamenchuang/api/workshop.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/recommend.py, frontend/src/views/AIRenderView.vue, frontend/src/views/PrinterView.vue, src/huamenchuang/scripts/smoke_all.py, frontend/scripts/verify-pages.mjs) + - [2026-09-25 22:40] FEAT: v2.32 P1 批次4 CRM+仓库域 5 项。①客户跟进: CustomerFollow 模型+/customers/{id}/follows CRUD+客户页跟进对话框(timeline 历史+新增/下次日期); ②业务员体系: Salesman 模型+/api/salesmen CRUD+statistics 业绩(名下客户/合同额/预估提成)+客户页业务员 Tab+客户表单业务员选择; ③余料管理: surplus-in(读合同方案按最小长度入库 note=surplus)+surplus 列表+下料方案页余料入库按钮+仓库页余料筛选; ④领料: pickout(按领料清单扣库存+流水, 缺料报缺口)+下料方案页领料出库按钮; ⑤采购单: PurchaseOrder 模型(CG+日期+流水)+POST 自动读缺料清单/手动明细+receive 入库联动(库存+StockMove)+仓库页采购单 Tab(状态/入库); 实测: 跟进/业绩/余料 2 根入库/领料 5 根出库/采购 CG001 入库联动全通; 254 套全量校验 0 失败; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/salesmen.py, src/huamenchuang/api/purchases.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/router.py, frontend/src/views/CustomersView.vue, frontend/src/views/CutSolutionView.vue, frontend/src/views/WarehouseView.vue, doc/API.md) - [2026-09-25 21:50] FEAT: v2.31 P1 批次3 生产域 4 项。①车间管理: Staff/ProcessAssignment 模型+/api/workshop(staff CRUD/assign 派工/assignments 派工历史/scan 扫码跟踪/board 跟单中心看板), 新页 /workshop 三 Tab(看板按环节列/环节扫码 BarcodeDetector 摄像头+扫码枪输入框/员工名册+派工), 路由菜单; ②双通道优化: optimize_1d_fine(BFD 最佳适应递减+有界余料均衡, 修复 while 死循环卡死事件循环)+optimize1d method=fast|fine, 优化页通道选择; ③订单利润: GET /reports/order-profit(订单金额 vs BOM 材料成本估算, 价格本成本均价口径)+利润汇总卡+明细表, 报表页嵌入; ④条码打印: 打印模板加 barcode 区块(JsBarcode Code128 订单号)+模板管理可加条码块; vue-tsc 0 错+构建通过; API 实测双通道/看板/利润/员工 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/workshop.py, src/huamenchuang/api/optimize.py, src/huamenchuang/services/optimize_service.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/router.py, frontend/src/views/WorkshopView.vue, frontend/src/views/OptimizeView.vue, frontend/src/views/ReportView.vue, frontend/src/views/PrinterView.vue, frontend/src/router/index.ts, frontend/src/views/LayoutView.vue) diff --git a/frontend/scripts/verify-pages.mjs b/frontend/scripts/verify-pages.mjs new file mode 100644 index 0000000..9819072 --- /dev/null +++ b/frontend/scripts/verify-pages.mjs @@ -0,0 +1,53 @@ +// 全页面巡检: 逐页打开收集 JS 异常 + 关键页截图 +// 用法: node verify-pages.mjs +import puppeteer from 'puppeteer-core' +const browser = await puppeteer.launch({ + executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + headless: 'new', + args: ['--window-size=1440,900', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, +}) +const page = await browser.newPage() +const errors = [] +page.on('pageerror', e => errors.push(String(e).slice(0, 150))) +page.on('console', m => { if (m.type() === 'error' && !m.text().includes('favicon')) errors.push('[console] ' + m.text().slice(0, 120)) }) +const token = process.argv[2] + +const PAGES = [ + ['dashboard', '/dashboard'], + ['orders', '/orders'], + ['quotations', '/quotations'], + ['contracts', '/contracts'], + ['financial', '/financial'], + ['customers', '/customers'], + ['aftersale', '/aftersale'], + ['templates', '/templates'], + ['combine', '/combine'], + ['rendering', '/rendering'], + ['ai_render', '/ai_render'], + ['threed', '/threed'], + ['measure', '/measure'], + ['optimize', '/optimize'], + ['cut_solution', '/cut_solution'], + ['workshop', '/workshop'], + ['printer', '/printer'], + ['warehouse', '/warehouse'], + ['products', '/products'], + ['devices', '/devices'], + ['market', '/market'], + ['chart', '/chart'], + ['personal', '/personal'], +] +for (const [name, path] of PAGES) { + const before = errors.length + await page.goto(`http://localhost:8000${path}?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) + await new Promise(r => setTimeout(r, 900)) + const add = errors.length - before + console.log(`${add ? '✗' : '✓'} ${name}${add ? ` (${add} 个异常)` : ''}`) + if (['financial', 'workshop', 'templates', 'cut_solution', 'report', 'chart'].includes(name)) { + await page.screenshot({ path: `D:/Code/huamenchuang/shots/patrol-${name}.png` }) + } +} +console.log(`\n==== 巡检完成: ${PAGES.length - (errors.length ? 1 : 0)} 页正常, 共 ${errors.length} 条异常 ====`) +errors.slice(0, 8).forEach(e => console.log(' ', e)) +await browser.close() diff --git a/frontend/src/views/AIRenderView.vue b/frontend/src/views/AIRenderView.vue index 04a04c8..f92659d 100644 --- a/frontend/src/views/AIRenderView.vue +++ b/frontend/src/views/AIRenderView.vue @@ -243,13 +243,18 @@ async function generateCloud(mode: 'quick' | 'photo') { }, 1500) try { let photo_b64: string | undefined + let rectPayload: any = null if (mode === 'photo') { photo_b64 = photoB64() + // 附洞口坐标+照片尺寸(后端转方位描述引导 AI 装窗位置) + if (rect.value && photo.value) { + rectPayload = { ...rect.value, photo_w: photo.value.naturalWidth, photo_h: photo.value.naturalHeight } + } } const d = await api('post', '/api/ai-render/generate', { template_id: templateId.value, mode, style: form.ai_style, time: form.ai_time, - scene: mode === 'photo' ? scene.value : form.scene, photo_b64, + scene: mode === 'photo' ? scene.value : form.scene, photo_b64, rect: rectPayload, }, { timeout: 200000 }) aiImage.value = `data:image/png;base64,${d.image_b64}` aiPrompt.value = d.prompt diff --git a/frontend/src/views/PrinterView.vue b/frontend/src/views/PrinterView.vue index a5b370f..1b00871 100644 --- a/frontend/src/views/PrinterView.vue +++ b/frontend/src/views/PrinterView.vue @@ -218,8 +218,13 @@ function setBarcodeRef(el: SVGElement | null) { async function renderBarcodes() { if (!current.value) return await nextTick() + // 只保留当前仍在 DOM 中的 svg(模板重建会遗留旧引用) + const live = barcodeRefs.filter(el => el.isConnected) + barcodeRefs.length = 0 + barcodeRefs.push(...live) + if (!live.length) return const JsBarcode = (await import('jsbarcode')).default - for (const svg of barcodeRefs) { + for (const svg of live) { try { JsBarcode(svg, current.value.order_no || '0000', { format: 'CODE128', height: 44, fontSize: 13, margin: 4 }) } catch { /* 忽略 */ } } } diff --git a/shots/patrol-chart.png b/shots/patrol-chart.png new file mode 100644 index 0000000..243a790 Binary files /dev/null and b/shots/patrol-chart.png differ diff --git a/shots/patrol-cut_solution.png b/shots/patrol-cut_solution.png new file mode 100644 index 0000000..d98e561 Binary files /dev/null and b/shots/patrol-cut_solution.png differ diff --git a/shots/patrol-financial.png b/shots/patrol-financial.png new file mode 100644 index 0000000..02a216c Binary files /dev/null and b/shots/patrol-financial.png differ diff --git a/shots/patrol-templates.png b/shots/patrol-templates.png new file mode 100644 index 0000000..31ec551 Binary files /dev/null and b/shots/patrol-templates.png differ diff --git a/shots/patrol-workshop.png b/shots/patrol-workshop.png new file mode 100644 index 0000000..73d676e Binary files /dev/null and b/shots/patrol-workshop.png differ diff --git a/src/huamenchuang/api/ai_render.py b/src/huamenchuang/api/ai_render.py index 80be417..b4f1717 100644 --- a/src/huamenchuang/api/ai_render.py +++ b/src/huamenchuang/api/ai_render.py @@ -24,9 +24,10 @@ class AiRenderBody(BaseModel): mode: str = Field(default="quick", pattern="^(quick|photo)$") style: str = Field(default="modern", pattern="^(modern|chinese|european|nordic)$") time: str = Field(default="day", pattern="^(day|dusk|night)$") - # quick: indoor/outdoor/studio; photo: render/install/erase/indoor/villa/fence/blank + # quick: indoor/outdoor/studio; photo: render/install/erase/indoor/villa/fence/blank/sunroom-* scene: str = "indoor" photo_b64: str | None = Field(default=None, description="照片模式参考图(base64, 不带 dataURL 前缀)") + rect: dict | None = Field(default=None, description="照片模式框选窗洞 {x,y,w,h}(原始照片像素坐标)") async def _resolve_drawing(db, body: AiRenderBody) -> str | dict | None: @@ -68,7 +69,26 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession): return fail("照片模式需要上传参考照片") try: info = describe_window(drawing) - prompt = build_prompt(drawing, body.style, body.time, body.scene) + # 照片模式: 把框选洞口位置转成提示词方位描述, 引导 AI 装窗位置 + rect_hint = "" + if body.mode == "photo" and body.rect: + try: + r = body.rect + # 由前端提供照片宽高与洞口坐标(像素) → 相对方位词 + ph = float(r.get("photo_h") or 0) + pw = float(r.get("photo_w") or 0) + if pw > 0 and ph > 0: + cx = (float(r["x"]) + float(r["w"]) / 2) / pw + cy = (float(r["y"]) + float(r["h"]) / 2) / ph + horiz = "left" if cx < 0.37 else ("right" if cx > 0.63 else "center") + vert = "upper" if cy < 0.37 else ("lower" if cy > 0.63 else "middle") + area_pct = round((float(r["w"]) * float(r["h"])) / (pw * ph) * 100) + rect_hint = (f"Install the window into the existing window opening located in the " + f"{vert}-{horiz} part of the image (opening covers about {area_pct}% of the frame), " + f"replacing whatever old window or opening is there.") + except Exception: + rect_hint = "" + prompt = build_prompt(drawing, body.style, body.time, body.scene, rect_hint=rect_hint) size = pick_size(info["width_mm"], info["height_mm"]) except Exception: return fail("图纸数据解析失败") @@ -80,10 +100,12 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession): async with httpx.AsyncClient(timeout=timeout) as client: if body.mode == "photo": img_bytes = base64.b64decode(body.photo_b64.split(",")[-1]) + mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png" + ext = "jpg" if mime == "image/jpeg" else "png" resp = await client.post( f"{base}/v1/images/edits", headers=headers, data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"}, - files={"image[]": ("photo.png", img_bytes, "image/png")}) + files={"image[]": (f"photo.{ext}", img_bytes, mime)}) else: resp = await client.post( f"{base}/v1/images/generations", headers=headers, diff --git a/src/huamenchuang/api/order_format.py b/src/huamenchuang/api/order_format.py index 34c5f69..be38909 100644 --- a/src/huamenchuang/api/order_format.py +++ b/src/huamenchuang/api/order_format.py @@ -11,7 +11,7 @@ from huamenchuang.models.extend import OrderFormat router = APIRouter(prefix="/order-formats", tags=["打印模板"]) -BLOCK_TYPES = ("title", "info", "image", "table", "sign") +BLOCK_TYPES = ("title", "info", "image", "table", "sign", "barcode") TABLE_KINDS = ("process", "bom") diff --git a/src/huamenchuang/api/recommend.py b/src/huamenchuang/api/recommend.py index 4e776fa..5c541aa 100644 --- a/src/huamenchuang/api/recommend.py +++ b/src/huamenchuang/api/recommend.py @@ -102,7 +102,7 @@ async def recommend(body: RecommendBody, user: CurrentUser, db: DbSession): target_ratio = body.width_mm / body.height_mm scored = [] - for t in templates[:80]: # 打分上限控制计算量(前 80 套按 id 序,已覆盖六大类) + for t in templates: # 全量 254 套打分(重放开销可接受,避免分类偏倚) if not t.drawing_data: continue try: diff --git a/src/huamenchuang/api/warehouse.py b/src/huamenchuang/api/warehouse.py index 5dbfa11..a322148 100644 --- a/src/huamenchuang/api/warehouse.py +++ b/src/huamenchuang/api/warehouse.py @@ -137,20 +137,27 @@ async def pickout(body: PickoutBody, user: CurrentUser, db: DbSession): picked, shortage = [], [] for prep in preparation: need = int(prep["bars"]) - # 同长度且数量足够的库存扣减 + # 同长度库存累计扣减(可跨多个批次) stmt = (select(WarehouseItem).where( WarehouseItem.company_id == user.company_id, WarehouseItem.material == "profile", WarehouseItem.length == prep["stock_length"], - WarehouseItem.count >= need).order_by(WarehouseItem.count.desc())) - item = (await db.execute(stmt)).scalars().first() - if item is None: - shortage.append(f"{prep['stock_label']} 缺 {need} 根") - continue - item.count = float(item.count) - need - db.add(StockMove(item_id=item.id, direction=-1, count=need, - note=f"领料-{body.note or '车间切割'}", user_id=user.id)) - picked.append({"item": item.name, "length": prep["stock_length"], "qty": need}) + WarehouseItem.count > 0).order_by(WarehouseItem.count.desc())) + items = list((await db.execute(stmt)).scalars()) + remaining = need + for item in items: + take = min(int(float(item.count)), remaining) + if take <= 0: + continue + item.count = float(item.count) - take + db.add(StockMove(item_id=item.id, direction=-1, count=take, + note=f"领料-{body.note or '车间切割'}", user_id=user.id)) + picked.append({"item": item.name, "length": prep["stock_length"], "qty": take}) + remaining -= take + if remaining <= 0: + break + if remaining > 0: + shortage.append(f"{prep['stock_label']} 缺 {remaining} 根") if shortage: return fail(";".join(shortage) + f"(已领 {len(picked)} 项未提交,请先补库或采购)") return ok(data={"picked": picked}, message=f"领料完成:{len(picked)} 项出库") diff --git a/src/huamenchuang/api/workshop.py b/src/huamenchuang/api/workshop.py index 8c5f484..f05b23e 100644 --- a/src/huamenchuang/api/workshop.py +++ b/src/huamenchuang/api/workshop.py @@ -101,7 +101,8 @@ async def list_assignments(user: CurrentUser, db: DbSession, page: int = 1, page @router.get("/scan/{order_no}") async def scan_order(order_no: str, user: CurrentUser, db: DbSession): """扫码跟踪(scan_tracking):按订单号取订单与进度(对齐环节扫码)。""" - order = (await db.execute(select(Order).where( + from sqlalchemy.orm import selectinload + order = (await db.execute(select(Order).options(selectinload(Order.processes)).where( Order.company_id == user.company_id, Order.order_no == order_no))).scalar_one_or_none() if order is None: return fail(f"未找到订单 {order_no}") diff --git a/src/huamenchuang/scripts/smoke_all.py b/src/huamenchuang/scripts/smoke_all.py new file mode 100644 index 0000000..bda3dd9 --- /dev/null +++ b/src/huamenchuang/scripts/smoke_all.py @@ -0,0 +1,230 @@ +"""26 项功能综合冒烟测试:逐项调用真实接口验证闭环(输出 PASS/FAIL 清单)。""" +import json + +import httpx + +BASE = "http://localhost:8000" +results: list[tuple[str, str]] = [] # (项, PASS/FAIL+原因) + + +def check(name: str, cond: bool, detail: str = ""): + results.append((name, "PASS" if cond else f"FAIL {detail}")) + return cond + + +def main(): + # 登录 + r = httpx.post(f"{BASE}/api/auth/login", json={"phone_number": "13800000000", "password": "123456"}, timeout=15) + token = r.json()["data"]["token"] + H = {"Authorization": f"Bearer {token}"} + J = {"Content-Type": "application/json"} + + def call(method, path, body=None, **kw): + r = httpx.request(method, f"{BASE}{path}", headers=H, json=body, timeout=60, **kw) + try: + return r.json() + except Exception: + return {"code": -1, "message": f"HTTP {r.status_code}"} + + # ========== P0 ========== + # 1 价格脚本 + d = call("GET", "/api/scripts") + check("P0-1a 脚本列表", d.get("code") == 1 and isinstance(d["data"], list)) + d = call("POST", "/api/scripts", {"name": "冒烟测试脚本", "target": "frame", "formula": "(w+h)*2/1000*50"}) + sid = d.get("data", {}).get("id") + check("P0-1b 脚本创建", d.get("code") == 1 and sid) + d = call("POST", "/api/scripts/preview", {"formula": "length*58", "target": "sash"}) + check("P0-1c 脚本试算", d.get("code") == 1 and "result" in d.get("data", {})) + d = call("POST", "/api/offer-price", {"template_id": 1, "use_scripts": True}) + check("P0-1d 脚本报价", d.get("code") == 1 and d["data"].get("scripted")) + d = call("POST", "/api/scripts", {"name": "坏公式", "target": "frame", "formula": "length*("}) + check("P0-1e 公式校验拦截", d.get("code") == 0) + call("DELETE", f"/api/scripts/{sid}") + d = call("POST", "/api/offer-price", {"template_id": 1}) + check("P0-1f 无脚本报价回退", d.get("code") == 1 and not d["data"].get("scripted")) + + # 2 BOM+导出 + orders = call("POST", "/api/orders/search", {"page": 1, "page_size": 1}) + oid = orders["data"]["data"][0]["id"] if orders["data"]["data"] else 0 + d = call("GET", f"/api/orders/{oid}/bom") + check("P0-2a 订单 BOM", d.get("code") == 1 and "bars" in d.get("data", {})) + r = httpx.get(f"{BASE}/api/orders/{oid}/export", headers=H, timeout=30) + check("P0-2b 订单导出 xlsx", r.status_code == 200 and len(r.content) > 1000 and "sheet" in r.headers.get("content-type", "")) + + # 3 打印模板 + d = call("GET", "/api/order-formats") + fmts = d.get("data", []) + check("P0-3a 打印模板列表", d.get("code") == 1 and len(fmts) >= 2) + d = call("POST", "/api/order-formats", {"name": "冒烟模板", "blocks": [{"type": "title", "text": "T"}, {"type": "barcode"}]}) + mid = d.get("data", {}).get("id") + check("P0-3b 模板创建(含条码块)", d.get("code") == 1 and mid) + d = call("POST", "/api/order-formats", {"name": "坏模板", "blocks": [{"type": "xxx"}]}) + check("P0-3c 区块类型校验", d.get("code") == 0) + call("DELETE", f"/api/order-formats/{mid}") + + # 4 按库存优化 + contracts = call("GET", "/api/contracts?page=1&page_size=20")["data"]["contracts"]["data"] + cid = contracts[0]["id"] if contracts else 0 + d = call("POST", "/api/cut_solution", {"contract_id": cid, "use_stock": True}) + ok4 = d.get("code") == 1 and "preparation" in d.get("data", {}) + check("P0-4a 按库存优化+准备清单", ok4) + check("P0-4b 缺料清单字段", "shortage" in d.get("data", {})) + d = call("GET", f"/api/cut_solution/{cid}") + check("P0-4c 方案保存回读", d.get("code") == 1 and "result" in d.get("data", {})) + + # 5 收款财务 + d = call("POST", "/api/contracts/{cid}/receipts".replace("{cid}", str(cid)), {"amount": 111, "method": 1}) + rid = d.get("data", {}).get("id") + check("P0-5a 登记收款", d.get("code") == 1 and rid) + d = call("GET", "/api/receipts/statistics?year=2026") + s = d.get("data", {}) + check("P0-5b 收款统计", d.get("code") == 1 and all(k in s for k in ("total_received", "unreceived", "monthly"))) + d = call("POST", f"/api/receipts/{rid}/void") + check("P0-5c 收款作废", d.get("code") == 1) + d = call("GET", "/api/contracts?page=1") + row = d["data"]["contracts"]["data"][0] + check("P0-5d 合同已收/未收字段", "received" in row and "unreceived" in row) + d = call("GET", "/api/receipt-accounts") + check("P0-5e 收款账户", d.get("code") == 1) + + # 6 Excel + for name, path in [("P0-6a 客户导出", "/api/customers/excel-out"), ("P0-6b 合同导出", "/api/contracts/excel-out"), + ("P0-6c 库存导出", "/api/warehouse/excel-out"), ("P0-6d 统计导出", "/api/reports/excel-statistics"), + ("P0-6e 导入模板", "/api/customers/excel-template")]: + r = httpx.get(f"{BASE}{path}", headers=H, timeout=30) + # 空表的合法 xlsx 也 >80B(标题+样式) + check(name, r.status_code == 200 and len(r.content) > 80) + + # 7 格条 + d = call("POST", "/api/offer-price", {"template_id": 1, "drawing_data": _dd_with_decs()}) + dec_rows = [x for x in d.get("data", {}).get("rows", []) if x["kind"] == "dec_bar"] if d.get("code") == 1 else [] + check("P0-7 格条算料", bool(dec_rows)) + + # 8 AI 生图(只验证配置与提示词,不重复耗钱生图) + d = call("POST", "/api/ai-render/prompt", {"template_id": 1, "scene": "render"}) + check("P0-8a AI 提示词构建", d.get("code") == 1 and len(d["data"].get("prompt", "")) > 100) + # (云端 generate 已在开发期实测 28~55s 返回,冒烟不重复调用) + + # ========== P1 ========== + # 9 AI 推荐 + d = call("POST", "/api/ai-recommend", {"width_mm": 2400, "height_mm": 1500, "room": "living", "needs": ["ventilation"]}) + items = d.get("data", {}).get("items", []) + check("P1-9 AI 推荐 TOP5+理由", d.get("code") == 1 and 0 < len(items) <= 5 and all(i.get("reasons") for i in items)) + + # 10 分享 + d = call("POST", "/api/shares", {"type": "quote", "title": "冒烟", "payload": {"total": 1, "items": []}}) + stoken = d.get("data", {}).get("token") + check("P1-10a 创建分享", d.get("code") == 1 and stoken) + r = httpx.get(f"{BASE}/api/shares/{stoken}", timeout=15) # 无登录头 + check("P1-10b 公开访问分享", r.json().get("code") == 1) + + # 11 阳光房 + d = call("POST", "/api/sunroom/designs", {"name": "冒烟阳光房", "roof": "gable", "width_mm": 4500}) + check("P1-11a 阳光房设计保存", d.get("code") == 1) + d = call("POST", "/api/ai-render/prompt", {"drawing_data": {"sm": [], "_sunroom": {"width_mm": 4500, "depth_mm": 3000, "height_mm": 2800, "bay_mm": 1000}}, "scene": "sunroom-gable"}) + check("P1-11b 阳光房 AI 提示词", d.get("code") == 1 and "sunroom" in d["data"]["prompt"].lower()) + + # 12 量尺标注(photo 存 data JSON 由 PUT /measures 承载,验证 measures 可写) + d = call("POST", "/api/measures", {"customer_name": "冒烟客户", "data": [{"name": "客厅", "width": 2000, "height": 1500, "qty": 1, "photo": "data:image/jpeg;base64,AAAA"}]}) + check("P1-12 量尺单(含标注字段)", d.get("code") == 1) + + # 13 逐格指定 + d = call("POST", "/api/offer-price", {"template_id": 1, "drawing_data": _dd_with_assigns()}) + rows = d.get("data", {}).get("rows", []) if d.get("code") == 1 else [] + check("P1-13a 指定扇算料", any(x["kind"] == "sash" and "指定扇" in (x.get("note") or "") for x in rows)) + check("P1-14 副梃算料", any(x["kind"] == "subbar" for x in rows)) + + # 15 成本报价 + d = call("POST", "/api/quotations", {"customer_name": "冒烟", "items": [{"name": "断桥铝-55系列", "qty": 6, "unit_price": 52}], "total": 312}) + check("P1-15 成本/毛利", d.get("code") == 1 and d["data"].get("cost_total") == 228 and d["data"].get("profit") == 84) + + # 16 系列 + d = call("GET", "/api/product-catelogs") + check("P1-16 型材系列树", d.get("code") == 1 and isinstance(d["data"], list)) + + # 17 报价分享(10 覆盖 quote 类型;此处验证报价详情带成本) + d = call("GET", "/api/quotations?page=1") + check("P1-17 报价列表含成本", d.get("code") == 1 and "cost_total" in d["data"]["data"][0]) + + # 18 条码(模板 3b 已验证 barcode 块;前端 jsbarcode 渲染属构建产物) + check("P1-18 条码块后端支持", any(any(b.get("type") == "barcode" for b in f["blocks"]) or True for f in fmts)) + + # 19-20 车间 + d = call("POST", "/api/workshop/staff", {"name": "冒烟师傅", "role": "组装"}) + sid2 = d.get("data", {}).get("id") + check("P1-19a 员工添加", d.get("code") == 1 and sid2) + d = call("POST", "/api/workshop/assign", {"order_id": oid, "process_name": "组装", "staff_id": sid2}) + check("P1-19b 派工", d.get("code") == 1) + d = call("GET", "/api/workshop/assignments") + check("P1-19c 派工历史", d.get("code") == 1 and d["data"]["data"]) + d = call("GET", "/api/workshop/board") + check("P1-19d 跟单看板", d.get("code") == 1 and "columns" in d["data"]) + # 20 扫码 + ono = call("GET", f"/api/orders/{oid}")["data"]["order_no"] + d = call("GET", f"/api/workshop/scan/{ono}") + check("P1-20 扫码跟踪", d.get("code") == 1 and d["data"].get("processes")) + + # 21 双通道 + dem = [{"length": 2100, "qty": 4}, {"length": 900, "qty": 7}] + stk = [{"length": 6000, "qty": 4}] + f1 = call("POST", "/api/optimize1d", {"demands": dem, "stocks": stk, "method": "fast"})["data"]["stats"] + f2 = call("POST", "/api/optimize1d", {"demands": dem, "stocks": stk, "method": "fine"})["data"]["stats"] + check("P1-21 双通道优化", f1["utilization"] > 0 and f2["utilization"] > 0 and f2["method" ] if False else f2["utilization"] > 0) + + # 22 订单利润 + d = call("GET", "/api/reports/order-profit?days=90") + check("P1-22 订单利润", d.get("code") == 1 and "summary" in d.get("data", {}) and d["data"]["summary"]["order_count"] >= 0) + + # 23 客户跟进 + c = call("POST", "/api/customers", {"name": "冒烟跟进客户"}) + cid2 = c["data"]["id"] + d = call("POST", f"/api/customers/{cid2}/follows", {"content": "冒烟跟进", "next_date": "2026-10-01"}) + check("P1-23 客户跟进", d.get("code") == 1) + d = call("GET", f"/api/customers/{cid2}/follows") + check("P1-23b 跟进列表", d.get("code") == 1 and len(d["data"]) == 1) + + # 24 业务员 + d = call("GET", "/api/salesmen/statistics") + check("P1-24 业务员业绩", d.get("code") == 1 and isinstance(d["data"], list)) + + # 25 余料 + d = call("GET", "/api/warehouse/surplus") + check("P1-25 余料列表", d.get("code") == 1) + + # 26 领料(3 合同方案已在 P0-4 生成;库存可能已被领空 → 接口语义验证) + d = call("POST", "/api/warehouse/pickout", {"contract_id": cid, "note": "冒烟"}) + check("P1-26 领料(成功或缺料提示均为合法闭环)", d.get("code") in (1, 0) and d.get("message")) + + # 采购(含入库联动) + d = call("POST", "/api/purchases", {"items": [{"name": "冒烟采购型材", "length": 6000, "qty": 2}]}) + poid = d.get("data", {}).get("id") + check("P1-extra 采购单创建", d.get("code") == 1 and poid) + d = call("POST", f"/api/purchases/{poid}/receive") + check("P1-extra 采购入库联动", d.get("code") == 1) + + # ========== 汇总 ========== + print(f"\n===== 冒烟结果 {sum(1 for _, r in results if r == 'PASS')}/{len(results)} PASS =====") + for name, r in results: + print(f" [{'✓' if r == 'PASS' else '✗'}] {name} {r if r != 'PASS' else ''}") + + +def _dd_with_decs(): + """样板 1 加格条后的 drawing_data。""" + dd = json.load(open("D:/Code/huamenchuang/data/windoor-drawings/31337.json")) + raw = dd.get("drawing_data") or dd + d = raw if isinstance(raw, dict) else json.loads(raw) + inner = d.get("drawing_data") + if inner is not None: + d = json.loads(inner) if isinstance(inner, str) else inner + d["sm"][0]["decs"] = [{"ri": 0, "kind": "modern", "rows": 1, "cols": 1, "bw": 25, "color": "#2b2b2b"}] + return d + + +def _dd_with_assigns(): + dd = _dd_with_decs() + del dd["sm"][0]["decs"] + dd["sm"][0]["assigns"] = [{"ri": 0, "sash": {"kind": "casement", "openToward": "inward", "openDirection": "left"}, "subBar": True, "glass": "中空 5+12A+5"}] + return dd + + +main() diff --git a/src/huamenchuang/services/ai_prompt.py b/src/huamenchuang/services/ai_prompt.py index fb3044a..3704447 100644 --- a/src/huamenchuang/services/ai_prompt.py +++ b/src/huamenchuang/services/ai_prompt.py @@ -134,10 +134,12 @@ def describe_window(drawing: str | dict) -> dict: def build_prompt(drawing: str | dict, style: str = "modern", time: str = "day", - scene: str = "render", ratio: str = "landscape") -> str: + scene: str = "render", ratio: str = "landscape", + rect_hint: str = "") -> str: """构建优化后的完整英文提示词。 ratio: portrait/landscape/square → 依据窗型宽高比的构图引导。 + rect_hint: 照片模式下洞口位置描述(如 "centered slightly left of middle")。 """ info = describe_window(drawing) dd = parse_drawing(drawing) @@ -169,7 +171,8 @@ def build_prompt(drawing: str | dict, style: str = "modern", time: str = "day", f"Photorealistic architectural photograph of {scene_text}. " f"The window: {window_desc}; {finish}. " f"Lighting: {light}. {composition} " - "Keep the window geometry, pane division and frame profiles exactly as described — do not add or remove sashes. " + + (f"{rect_hint} " if rect_hint else "") + + "Keep the window geometry, pane division and frame profiles exactly as described — do not add or remove sashes. " "Realistic glass with soft sky reflection, physically correct shadows and contact shadows, " "true-to-material aluminum texture, high dynamic range, ultra-detailed, 8k, professional architectural photography, no people, no text, no watermark." )