test: 26项功能落地性复查(综合冒烟52项+23页巡检, 修复6处缺陷)

This commit is contained in:
HHY
2026-09-25 07:11:37 -07:00
parent 543a94609c
commit 7320ac6240
16 changed files with 348 additions and 20 deletions
+25 -3
View File
@@ -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,
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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:
+17 -10
View File
@@ -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)} 项出库")
+2 -1
View File
@@ -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}")
+230
View File
@@ -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()
+5 -2
View File
@@ -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."
)