feat: 部位级材料选择(对应部位使用对应材料: 估算器overrides+series-materials聚合端点+QuoteDialog部位选材面板实时重算)

This commit is contained in:
HHY
2026-10-06 05:58:10 -07:00
parent 07eda3ff35
commit 5abfe61beb
6 changed files with 143 additions and 14 deletions
+39
View File
@@ -57,6 +57,45 @@ async def list_series(user: CurrentUser, db: DbSession):
return ok(data=[_brief(t) for t in rows])
@router.get("/costbook/series-materials")
async def series_materials(user: CurrentUser, db: DbSession):
"""聚合全部系列模板的用料清单,按部位分组(frame/mullion/sash/pressline/glass/hardware)。
供商家「按部位选材」下拉:跨系列混搭(如外框用 85 系列的 33框、扇用 75 的内开扇)。
"""
import json as _json
from huamenchuang.services.costbook_service import _profile_role
rows = list((await db.execute(select(CostSeriesTemplate).where(
CostSeriesTemplate.company_id == user.company_id, CostSeriesTemplate.enabled == 1)
.order_by(CostSeriesTemplate.series))).scalars())
groups: dict[str, list] = {"frame": [], "mullion": [], "sash": [], "pressline": [], "glass": [], "hardware": []}
seen: set = set()
def push(role: str, item: dict):
key = (role, item.get("name"), item.get("unit_price"))
if key in seen:
return
seen.add(key)
groups[role].append(item)
for t in rows:
try:
data = _json.loads(t.data)
except Exception:
continue
for p in data.get("profile", []):
role = _profile_role(p.get("name", ""))
if role:
push(role, {"name": p.get("name"), "unit_price": p.get("unit_price"),
"weight_per_m": p.get("weight_per_m"), "series": t.name})
for g in data.get("glass", []):
push("glass", {"name": g.get("config"), "unit_price": g.get("unit_price"), "series": t.name})
for hw in data.get("hardware", []):
push("hardware", {"name": hw.get("name"), "unit_price": hw.get("unit_price"), "series": t.name})
return ok(data=groups)
class EstimateBody(BaseModel):
series_id: int
width: float = Field(gt=0, description="窗宽 mm")
+3 -1
View File
@@ -15,6 +15,7 @@ class QuoteRequest(BaseModel):
template_id: int | None = Field(default=None, description="或直接指定样板")
order_id: int | None = Field(default=None, description="或对已有订单报价")
series_id: int | None = Field(default=None, description="材料系列(成本系列模板): 附加该系列的成本估算供商家选材")
materials: dict | None = Field(default=None, description="部位级材料覆盖 {frame,mullion,sash,glass,hardware: {name,unit_price,weight_per_m}}——对应部位使用对应材料(可跨系列)")
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
use_price_book: bool = Field(default=False, description="从产品管理价格本取价(按材料类别均价)")
use_scripts: bool = Field(default=False, description="启用价格脚本计价(公司启用脚本覆盖默认单价/总价)")
@@ -88,12 +89,13 @@ async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession):
if tpl is not None and tpl.company_id == user.company_id:
w = result.get("width_mm") or 1600
h = result.get("height_mm") or 1600
est = _svc.estimate(tpl, w, h, 1)
est = _svc.estimate(tpl, w, h, 1, material_overrides=body.materials)
result["series"] = {"id": tpl.id, "name": tpl.name, "series": tpl.series, "wall_thick": tpl.wall_thick}
result["series_estimate"] = {
"width": w, "height": h, "total": est["total"], "per_sqm": est["per_sqm"],
"profile_cost": est["profile_cost"], "glass_cost": est["glass_cost"],
"hardware_cost": est["hardware_cost"], "labor_cost": est["labor_cost"],
"margin_ladder": est["margin_ladder"],
"overrides": est.get("overrides"),
}
return ok(data=result, message="报价成功")
+47 -8
View File
@@ -360,10 +360,34 @@ async def import_series(db: AsyncSession, company_id: int, parsed: dict) -> int:
# ---------------- 估算器 ----------------
def _profile_role(name: str) -> str | None:
"""型材名称 → 部位角色(frame/mullion/sash/pressline)。"""
if "压线" in name:
return "pressline"
if "中挺" in name or "中梃" in name:
return "mullion"
if "扇" in name:
return "sash"
if "框" in name:
return "frame"
return None
def estimate(tpl: CostSeriesTemplate, width_mm: float, height_mm: float,
count: int = 1, sash_count: int | None = None) -> dict:
"""按系列模板估算任意尺寸窗型的成本明细(口径对齐核算表)。"""
count: int = 1, sash_count: int | None = None,
material_overrides: dict | None = None) -> dict:
"""按系列模板估算任意尺寸窗型的成本明细(口径对齐核算表)。
material_overrides: 部位级材料覆盖(商家按部位选材,可跨系列混搭)
{"frame": {"name","unit_price","weight_per_m"}, "mullion": {...}, "sash": {...},
"glass": {"name","unit_price"}, "hardware": {"name","unit_price"}}
匹配规则: profile 按 _profile_role(名称) 归属部位, 命中的行替换名称/单价/米重;
glass/hardware 按行序整类替换单价(名称可换)。
"""
data = json.loads(tpl.data)
ov = material_overrides or {}
ov_frame, ov_mullion, ov_sash = ov.get("frame"), ov.get("mullion"), ov.get("sash")
ov_by_role = {"frame": ov_frame, "mullion": ov_mullion, "sash": ov_sash}
win = data["window"]
base_area = win.get("area") or (win.get("w", 1600) * win.get("h", 1600) / 1e6)
area = width_mm * height_mm / 1e6 * count
@@ -404,9 +428,20 @@ def estimate(tpl: CostSeriesTemplate, width_mm: float, height_mm: float,
"width": width_mm, "height": height_mm, "count": count,
"sash_count": sash_count, "area": round(area, 4),
"open_area": round(new_sash_area, 4), "fixed_area": round(new_fixed_area, 4)}
if ov:
out["overrides"] = {k: v for k, v in ov.items() if v}
sections_total = 0.0
detail, cost = cost_of(data.get("profile", []))
# 部位级材料覆盖: 按角色命中替换(商家跨系列选材)
def apply_profile_ov(rows: list[dict]) -> list[dict]:
out_rows = []
for r in rows:
role = _profile_role(r.get("name", ""))
o = ov_by_role.get(role)
out_rows.append({**r, **{k: v for k, v in o.items() if v is not None}} if o else r)
return out_rows
detail, cost = cost_of(apply_profile_ov(data.get("profile", [])))
out["profile"], sections_total = detail, sections_total + cost
out["profile_cost"] = round(cost, 2)
detail, cost = cost_of(data.get("addon", []))
@@ -419,24 +454,28 @@ def estimate(tpl: CostSeriesTemplate, width_mm: float, height_mm: float,
out["seal"], out["seal_cost"] = detail, round(cost, 2)
sections_total += cost
# 玻璃:模板 area 已是该配置块数总面积,按面积比例缩放
ov_glass = ov.get("glass")
glass_detail, glass_cost = [], 0.0
for g in data.get("glass", []):
g_ratio = ratio["open"] if g.get("alloc") == "open" else ratio["fixed"]
amount = g["area"] * g_ratio * g.get("unit_price", 0)
g_row = {**g, **{k: v for k, v in (ov_glass or {}).items() if v is not None}} if ov_glass else g
amount = g_row["area"] * g_ratio * g_row.get("unit_price", 0)
glass_cost += amount
glass_detail.append({**g, "blocks": round(g["count"] * g_ratio, 2),
glass_detail.append({**g_row, "blocks": round(g["count"] * g_ratio, 2),
"area": round(g["area"] * g_ratio, 4), "amount": round(amount, 2)})
out["glass"], out["glass_cost"] = glass_detail, round(glass_cost, 2)
sections_total += glass_cost
detail, cost = cost_of(data.get("extra", []))
out["extra"], out["extra_cost"] = detail, round(cost, 2)
sections_total += cost
ov_hw = ov.get("hardware")
hw_detail, hw_cost = [], 0.0
for hw in data.get("hardware", []):
qty = sash_count * (hw.get("count") or 1)
amount = qty * hw.get("unit_price", 0)
hw_row = {**hw, **{k: v for k, v in (ov_hw or {}).items() if v is not None}} if ov_hw else hw
qty = sash_count * (hw_row.get("count") or 1)
amount = qty * hw_row.get("unit_price", 0)
hw_cost += amount
hw_detail.append({**hw, "qty": qty, "amount": round(amount, 2)})
hw_detail.append({**hw_row, "qty": qty, "amount": round(amount, 2)})
out["hardware"], out["hardware_cost"] = hw_detail, round(hw_cost, 2)
sections_total += hw_cost