feat: 部位级材料选择(对应部位使用对应材料: 估算器overrides+series-materials聚合端点+QuoteDialog部位选材面板实时重算)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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="报价成功")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user