feat: v0.3 报价算料引擎与仓库库存模块

后端:
- 报价算料: 从drawing_data几何提取型材清单(外框/中梃/扇框投影长度)
  与玻璃面积(鞋带公式), 套价格本计价, 支持样板/订单/自定义图纸三入口
- 仓库库存: 材料CRUD/出入库/流水/负库存拦截/预警线
- SQLite默认路径锚定项目根目录(修复cwd依赖)
前端:
- 仓库库存页(筛选/出入库对话框/预警高亮)
- 算料报价弹窗(样板库与订单详情入口, 支持打印)
- 路由与导航更新(8页面)
回归: 9个API端点全部200, 报价/优化/SPA路由验证通过
This commit is contained in:
HHY
2026-09-20 08:58:14 -07:00
parent f7a555dd6c
commit 065d387399
16 changed files with 630 additions and 8 deletions
+147
View File
@@ -0,0 +1,147 @@
"""报价算料服务:从 drawing_data 几何信息提取型材下料清单与玻璃面积,套价格本计价。
提取规则(自研 v1,工程近似):
- 外框:每 Frame 形状 2 根宽 + 2 根高(忽略 45° 拼角损耗)
- 中梃:每条分割线按其在框内投影长度计(竖梃=框高,横梃=框宽)
- 扇框:每个 Sash 4 根(2×扇宽 + 2×扇高)
- 玻璃:全部玻璃区域面积之和(鞋带公式)
"""
from dataclasses import dataclass, field
from huamenchuang.core.drawing import DrawingData, parse_drawing
@dataclass
class BarItem:
"""型材清单行。"""
kind: str # frame/mullion/sash
length: int # mm
qty: int = 1
note: str = ""
@dataclass
class TakeoffResult:
bars: list[BarItem] = field(default_factory=list)
glass_area_m2: float = 0.0
width_mm: int = 0
height_mm: int = 0
def _shoelace_area(pts: list[tuple[float, float]]) -> float:
"""多边形面积(鞋带公式)。"""
n = len(pts)
s = 0.0
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[(i + 1) % n]
s += x1 * y2 - x2 * y1
return abs(s) / 2
def _region_bbox(region_pts):
xs = [p[0] for p in region_pts]
ys = [p[1] for p in region_pts]
return min(xs), min(ys), max(xs), max(ys)
def takeoff(dd: DrawingData) -> TakeoffResult:
"""从解析后的 drawing_data 提取算料清单。"""
result = TakeoffResult()
xs, ys = [], []
for shape in dd.get("sm", []):
regions = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r]
for r in ((shape.get("polygon") or {}).get("polygon") or [])]
if not regions:
continue
# 形状外框 bbox
all_pts = [p for r in regions for p in r]
x0, y0, x1, y1 = _region_bbox(all_pts)
w, h = round(x1 - x0), round(y1 - y0)
xs.extend([x0, x1])
ys.extend([y0, y1])
result.bars.append(BarItem("frame", w, 2, "外框-宽"))
result.bars.append(BarItem("frame", h, 2, "外框-高"))
# 中梃:投影长度(竖梃=框高,横梃=框宽)
for sp in ((shape.get("mm") or {}).get("sp") or []):
norm = sp["line"]["norm"]
if abs(norm["x"]) >= abs(norm["y"]):
result.bars.append(BarItem("mullion", h, 1, "竖梃"))
else:
result.bars.append(BarItem("mullion", w, 1, "横梃"))
# 玻璃面积
result.glass_area_m2 += sum(_shoelace_area(r) for r in regions) / 1_000_000
# 扇框
for sub in (shape.get("sm") or []):
bd = sub.get("bd") if isinstance(sub, dict) else None
if not bd or bd.get("hidden"):
continue
bw = round(abs(bd["to"]["x"] - bd["from"]["x"]))
bh = round(abs(bd["to"]["y"] - bd["from"]["y"]))
if bw < 10 or bh < 10:
continue
result.bars.append(BarItem("sash", bw, 2, "扇框-宽"))
result.bars.append(BarItem("sash", bh, 2, "扇框-高"))
if xs:
result.width_mm = round(max(xs) - min(xs))
result.height_mm = round(max(ys) - min(ys))
return result
def merge_bars(bars: list[BarItem]) -> list[dict]:
"""同长度同类合并。"""
merged: dict[tuple, dict] = {}
for b in bars:
key = (b.kind, b.length, b.note)
if key in merged:
merged[key]["qty"] += b.qty
else:
merged[key] = {"kind": b.kind, "length": b.length, "note": b.note, "qty": b.qty}
return sorted(merged.values(), key=lambda x: -x["length"])
def quote(drawing: str | dict, prices: dict | None = None) -> dict:
"""完整报价:算料 + 套价。
prices 结构: {frame: 元/米, mullion: 元/米, sash: 元/米, glass: 元/㎡, hardware: 元/扇}
"""
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180}
if prices:
p.update(prices)
dd = parse_drawing(drawing)
t = takeoff(dd)
rows, total = [], 0.0
unit_kind = {"frame": "frame", "mullion": "mullion", "sash": "sash"}
for b in merge_bars(t.bars):
amount = b["length"] * b["qty"] / 1000 * p[unit_kind[b["kind"]]]
total += amount
rows.append({**b, "unit_price": p[unit_kind[b["kind"]]], "unit": "元/米", "amount": round(amount, 2)})
glass_amount = t.glass_area_m2 * p["glass"]
total += glass_amount
rows.append({"kind": "glass", "length": 0, "note": "玻璃", "qty": round(t.glass_area_m2, 3),
"unit_price": p["glass"], "unit": "元/㎡", "amount": round(glass_amount, 2)})
sash_count = sum(
1 for s in dd.get("sm", []) for sub in (s.get("sm") or [])
if isinstance(sub, dict) and sub.get("bd") and not sub.get("bd").get("hidden")
)
hw_amount = sash_count * p["hardware"]
if sash_count:
rows.append({"kind": "hardware", "length": 0, "note": "五金", "qty": sash_count,
"unit_price": p["hardware"], "unit": "元/扇", "amount": round(hw_amount, 2)})
total += hw_amount
return {
"rows": rows,
"glass_area_m2": round(t.glass_area_m2, 3),
"width_mm": t.width_mm,
"height_mm": t.height_mm,
"total": round(total, 2),
}