feat: 部位级材料选择(对应部位使用对应材料: 估算器overrides+series-materials聚合端点+QuoteDialog部位选材面板实时重算)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-10-06 05:57] FEAT: v2.43 部位级材料选择——对应部位使用对应材料。估算器 estimate() 增加 material_overrides(部位级覆盖: profile 按 _profile_role 名称归位 frame/mullion/sash/pressline, 命中行替换名称/单价/米重; glass/hardware 整类替换)+overrides 回显; 新增 GET /api/costbook/series-materials(聚合全部系列用料按部位分组去重, 供跨系列混搭: frame 3 种/mullion 3/sash 2/pressline 6/glass 5/hardware 1); offer-price 新增 materials 参数透传; QuoteDialog 部位选材面板(外框型材/中梃型材/扇框型材/玻璃配置/五金套件 五下拉, 选项带价格与系列来源, 改选即重算 offer-price 实时刷新系列成本); 端到端实测: 样板704+系列75, 外框改选 95 系列 33框(25.88元/kg) → 系列成本 1251.10→1303.57 实时重算(手算吻合), 报价总额独立对比; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/quote.py, src/huamenchuang/api/costbook.py, frontend/src/components/QuoteDialog.vue, doc/API.md)
|
||||||
- [2026-10-06 08:05] FEAT: v2.42 样板库整合材料系列选择——商家选材闭环。样板库预览对话框(编辑/非编辑态)新增「材料系列」选择器(GET /api/costbook/series: 75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, 来自鲁铭成本核算 Excel); 选择后算料报价携带 series_id → /api/offer-price 响应附加 series(系列名/壁厚)+series_estimate(按图纸实际宽高的系列八分区成本估算: 总成本/每平/型材/玻璃/五金/人工/margin_ladder 毛利阶梯); QuoteDialog 新增「材料系列成本参考」面板(统计卡+分区明细+建议售价50%毛利), 与报价总额并列对比辅助商家决策; 端到端实测: 样板704(1800x1600)+系列75 → 报价761.08元, 系列成本参考1251.1元/樘(每平434.41, 50%毛利建议1876.65); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/quote.py, frontend/src/components/QuoteDialog.vue, frontend/src/views/TemplatesView.vue, doc/API.md)
|
- [2026-10-06 08:05] FEAT: v2.42 样板库整合材料系列选择——商家选材闭环。样板库预览对话框(编辑/非编辑态)新增「材料系列」选择器(GET /api/costbook/series: 75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, 来自鲁铭成本核算 Excel); 选择后算料报价携带 series_id → /api/offer-price 响应附加 series(系列名/壁厚)+series_estimate(按图纸实际宽高的系列八分区成本估算: 总成本/每平/型材/玻璃/五金/人工/margin_ladder 毛利阶梯); QuoteDialog 新增「材料系列成本参考」面板(统计卡+分区明细+建议售价50%毛利), 与报价总额并列对比辅助商家决策; 端到端实测: 样板704(1800x1600)+系列75 → 报价761.08元, 系列成本参考1251.1元/樘(每平434.41, 50%毛利建议1876.65); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/quote.py, frontend/src/components/QuoteDialog.vue, frontend/src/views/TemplatesView.vue, doc/API.md)
|
||||||
- [2026-10-05 07:52] FIX: v2.41 门窗渲染语义区分(对齐官方图纸)。对照官方参考图(windoor-images/31337/31343/31637)逐项核对门的视觉语义, 三渲染器同步: ①门把手贯穿长条(约55%扇高, 官方门统一长条贯穿中缝侧) vs 窗短把手(20%) ②门上下锁点小方块(RenderCanvas 此前缺失, 补齐; WindoorCanvas 已有) ③裙板色=型材同色系(RenderCanvas 取 shape.cm.g 暗化 62%, 解析失败退中性深灰; WindoorCanvas 图纸深灰+竖纹肌理+浅色编号; 3D 0x606369)——此前裙板米色与型材脱节; 门窗开向符号(V形虚线顶点指铰链侧)经官方图核对为同一语义无需区分; 视觉验收: 31343/31337 门(长把手+锁点+型材色裙板)与 704 窗(短把手)对比官方图一致; 回归: 24 页巡检 0 异常+冒烟 60/60 (Files: frontend/src/components/RenderCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/components/WindoorThreed.vue)
|
- [2026-10-05 07:52] FIX: v2.41 门窗渲染语义区分(对齐官方图纸)。对照官方参考图(windoor-images/31337/31343/31637)逐项核对门的视觉语义, 三渲染器同步: ①门把手贯穿长条(约55%扇高, 官方门统一长条贯穿中缝侧) vs 窗短把手(20%) ②门上下锁点小方块(RenderCanvas 此前缺失, 补齐; WindoorCanvas 已有) ③裙板色=型材同色系(RenderCanvas 取 shape.cm.g 暗化 62%, 解析失败退中性深灰; WindoorCanvas 图纸深灰+竖纹肌理+浅色编号; 3D 0x606369)——此前裙板米色与型材脱节; 门窗开向符号(V形虚线顶点指铰链侧)经官方图核对为同一语义无需区分; 视觉验收: 31343/31337 门(长把手+锁点+型材色裙板)与 704 窗(短把手)对比官方图一致; 回归: 24 页巡检 0 异常+冒烟 60/60 (Files: frontend/src/components/RenderCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/components/WindoorThreed.vue)
|
||||||
- [2026-10-05 07:30] TEST: v2.40 全量 254 套模板视觉校验——v2.39 消费路径全量回归 0 缺陷。新增两个常驻校验资产: ①frontend/scripts/verify-sash-panels.mjs(结构级: 渲染器同路径全量跑 重放/扇推断/锚点匹配/inferSashPanels, A~E 五级异常分类+子格覆盖度断言, 实测 254 文件/409 形状/570 扇/726 子格(裙板 150/格条 58) 0 异常) ②frontend/scripts/patrol-render-all.mjs(渲染级: 无头浏览器逐套切换样板真实渲染, 收集 JS 异常+画布非透明像素活性, 实测 254/254 OK 0 空白 0 异常); 6 套代表性样板视觉抽验通过(704 平开窗/31637 推拉门格条裙板/31343 双扇门裙板/31353 三扇推拉16条扇内分割/31593 平开窗/31603 四扇推拉)——v2.39 为渲染器/工具层共性修复, 全量样板自动受益, 无需逐套修改 (Files: frontend/scripts/verify-sash-panels.mjs, frontend/scripts/patrol-render-all.mjs)
|
- [2026-10-05 07:30] TEST: v2.40 全量 254 套模板视觉校验——v2.39 消费路径全量回归 0 缺陷。新增两个常驻校验资产: ①frontend/scripts/verify-sash-panels.mjs(结构级: 渲染器同路径全量跑 重放/扇推断/锚点匹配/inferSashPanels, A~E 五级异常分类+子格覆盖度断言, 实测 254 文件/409 形状/570 扇/726 子格(裙板 150/格条 58) 0 异常) ②frontend/scripts/patrol-render-all.mjs(渲染级: 无头浏览器逐套切换样板真实渲染, 收集 JS 异常+画布非透明像素活性, 实测 254/254 OK 0 空白 0 异常); 6 套代表性样板视觉抽验通过(704 平开窗/31637 推拉门格条裙板/31343 双扇门裙板/31353 三扇推拉16条扇内分割/31593 平开窗/31603 四扇推拉)——v2.39 为渲染器/工具层共性修复, 全量样板自动受益, 无需逐套修改 (Files: frontend/scripts/verify-sash-panels.mjs, frontend/scripts/patrol-render-all.mjs)
|
||||||
|
|||||||
+2
-1
@@ -264,7 +264,7 @@
|
|||||||
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 算料报价 | POST | `/api/offer-price` | `{drawing_data \| template_id \| order_id(三选一), prices?, series_id?}` | 从 drawing_data 几何提取:外框/中梃/扇框型材清单(工程近似)+ 玻璃面积(鞋带公式)+ 五金,套价格本计价。默认价: 型材40-50元/米、玻璃120元/㎡、五金180元/扇;`prices` 可覆盖 |
|
| 算料报价 | POST | `/api/offer-price` | `{drawing_data \| template_id \| order_id(三选一), prices?, series_id?}` | 从 drawing_data 几何提取:外框/中梃/扇框型材清单(工程近似)+ 玻璃面积(鞋带公式)+ 五金,套价格本计价。默认价: 型材40-50元/米、玻璃120元/㎡、五金180元/扇;`prices` 可覆盖 |
|
||||||
| **系列选材** | (series_id 参数) | — | `series_id`(成本系列模板 id,见 12.27) | 传入后响应附加 `series`(系列名/壁厚)与 `series_estimate`(按图纸实际尺寸的系列成本估算: total/per_sqm/分区成本/margin_ladder 毛利阶梯)——商家在样板库选择材料系列后算料报价即得该系列的选材成本参考 |
|
| **系列选材** | (series_id/materials 参数) | — | `series_id`(成本系列模板 id,见 12.27);`materials` 部位级材料覆盖 `{frame,mullion,sash,glass,hardware: {name,unit_price,weight_per_m}}` | 传入后响应附加 `series`(系列名/壁厚)与 `series_estimate`(按图纸实际尺寸的系列成本估算: total/per_sqm/分区成本/margin_ladder 毛利阶梯/overrides 回显)——**对应部位使用对应材料**: 商家可为外框/中梃/扇框/玻璃/五金分别选材(可跨系列混搭), 估算按所选材料单价实时重算 |
|
||||||
|
|
||||||
### 12.9 仓库库存 `/api/warehouse`(v0.3 新增)
|
### 12.9 仓库库存 `/api/warehouse`(v0.3 新增)
|
||||||
|
|
||||||
@@ -538,6 +538,7 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|
|||||||
| 新建系列模板 | POST | `/api/costbook/series` | `{name,series,wall_thick,data}` | `{id}` |
|
| 新建系列模板 | POST | `/api/costbook/series` | `{name,series,wall_thick,data}` | `{id}` |
|
||||||
| 删除系列模板 | DELETE | `/api/costbook/series/{id}` | — | `{code:1}` |
|
| 删除系列模板 | DELETE | `/api/costbook/series/{id}` | — | `{code:1}` |
|
||||||
| **成本估算** | POST | `/api/costbook/estimate` | `{series_id,width,height,count=1,sash_count?}` | 见下 |
|
| **成本估算** | POST | `/api/costbook/estimate` | `{series_id,width,height,count=1,sash_count?}` | 见下 |
|
||||||
|
| 部位材料清单 | GET | `/api/costbook/series-materials` | — | 聚合全部系列用料按部位分组(frame/mullion/sash/pressline/glass/hardware),供报价「按部位选材」下拉(可跨系列混搭) |
|
||||||
|
|
||||||
**估算响应**(口径对齐核算表:固定类料随固定面积、开启类随开启面积、once 类按樘数缩放;人工/其他成本优先用核算表每平实际口径):
|
**估算响应**(口径对齐核算表:固定类料随固定面积、开启类随开启面积、once 类按樘数缩放;人工/其他成本优先用核算表每平实际口径):
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,18 @@
|
|||||||
<span class="dim">按当前窗型 {{ result.series_estimate.width }}×{{ result.series_estimate.height }}mm 估算</span>
|
<span class="dim">按当前窗型 {{ result.series_estimate.width }}×{{ result.series_estimate.height }}mm 估算</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-row :gutter="10">
|
<!-- 部位选材: 对应部位使用对应材料(可跨系列混搭) -->
|
||||||
|
<div class="part-row">
|
||||||
|
<div v-for="p in partKeys" :key="p.key" class="part-item">
|
||||||
|
<div class="part-label">{{ p.label }}</div>
|
||||||
|
<el-select v-model="parts[p.key]" placeholder="系列默认" clearable filterable size="small"
|
||||||
|
style="width:100%" @change="requote">
|
||||||
|
<el-option v-for="(m, i) in (materialGroups[p.key] ?? [])" :key="i" :value="i"
|
||||||
|
:label="`${m.name} · ${m.unit_price}${p.key === 'glass' ? '元/㎡' : '元'}${m.series ? ' · ' + m.series : ''}`" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="10" style="margin-top:10px">
|
||||||
<el-col :span="6"><el-statistic title="系列成本(元/樘)" :value="result.series_estimate.total" :precision="2" /></el-col>
|
<el-col :span="6"><el-statistic title="系列成本(元/樘)" :value="result.series_estimate.total" :precision="2" /></el-col>
|
||||||
<el-col :span="6"><el-statistic title="每平成本(元/㎡)" :value="result.series_estimate.per_sqm" :precision="2" /></el-col>
|
<el-col :span="6"><el-statistic title="每平成本(元/㎡)" :value="result.series_estimate.per_sqm" :precision="2" /></el-col>
|
||||||
<el-col :span="6"><el-statistic title="建议售价(50%毛利)" :value="result.series_estimate.margin_ladder['50%']" :precision="2" /></el-col>
|
<el-col :span="6"><el-statistic title="建议售价(50%毛利)" :value="result.series_estimate.margin_ladder['50%']" :precision="2" /></el-col>
|
||||||
@@ -58,7 +69,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -66,6 +77,34 @@ const loading = ref(false)
|
|||||||
const result = ref<any>(null)
|
const result = ref<any>(null)
|
||||||
let source: { drawing_data?: string; template_id?: number; order_id?: number; series_id?: number } = {}
|
let source: { drawing_data?: string; template_id?: number; order_id?: number; series_id?: number } = {}
|
||||||
|
|
||||||
|
// 部位选材: 对应部位使用对应材料(选项聚合自各系列用料清单, 可跨系列混搭)
|
||||||
|
const partKeys = [
|
||||||
|
{ key: 'frame', label: '外框型材' },
|
||||||
|
{ key: 'mullion', label: '中梃型材' },
|
||||||
|
{ key: 'sash', label: '扇框型材' },
|
||||||
|
{ key: 'glass', label: '玻璃配置' },
|
||||||
|
{ key: 'hardware', label: '五金套件' },
|
||||||
|
] as const
|
||||||
|
const materialGroups = ref<Record<string, any[]>>({})
|
||||||
|
const parts = reactive<Record<string, number | undefined>>({ frame: undefined, mullion: undefined, sash: undefined, glass: undefined, hardware: undefined })
|
||||||
|
|
||||||
|
function buildMaterials(): Record<string, any> | undefined {
|
||||||
|
const out: Record<string, any> = {}
|
||||||
|
for (const { key } of partKeys) {
|
||||||
|
const idx = parts[key]
|
||||||
|
const m = idx !== undefined ? (materialGroups.value[key] ?? [])[idx] : undefined
|
||||||
|
if (m) out[key] = { name: m.name, unit_price: m.unit_price, weight_per_m: m.weight_per_m }
|
||||||
|
}
|
||||||
|
return Object.keys(out).length ? out : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requote() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
result.value = await api<any>('post', '/api/offer-price', { ...source, materials: buildMaterials() })
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
function kindLabel(k: string) {
|
function kindLabel(k: string) {
|
||||||
return ({ frame: '外框', mullion: '中梃', sash: '扇框', glass: '玻璃', hardware: '五金' } as Record<string, string>)[k] ?? k
|
return ({ frame: '外框', mullion: '中梃', sash: '扇框', glass: '玻璃', hardware: '五金' } as Record<string, string>)[k] ?? k
|
||||||
}
|
}
|
||||||
@@ -74,6 +113,11 @@ async function open(src: typeof source) {
|
|||||||
source = src
|
source = src
|
||||||
visible.value = true
|
visible.value = true
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
// 重置部位选择
|
||||||
|
for (const { key } of partKeys) parts[key] = undefined
|
||||||
|
if (src.series_id && !Object.keys(materialGroups.value).length) {
|
||||||
|
try { materialGroups.value = await api<any>('get', '/api/costbook/series-materials') } catch { /* 无系列数据时隐藏 */ }
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
result.value = await api<any>('post', '/api/offer-price', src)
|
result.value = await api<any>('post', '/api/offer-price', src)
|
||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
@@ -89,7 +133,10 @@ defineExpose({ open })
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.total { color: #e6483d; font-size: 18px; font-weight: 700; }
|
.total { color: #e6483d; font-size: 18px; font-weight: 700; }
|
||||||
.series-card { margin-top: 10px; }
|
.series-card { margin-top: 10px; }
|
||||||
.series-head { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
.series-head { display: flex; align-items: center; gap: 8px; font-weight: 600; flex-wrap: wrap; }
|
||||||
.series-head .dim { font-size: 12px; color: #909399; font-weight: 400; }
|
.series-head .dim { font-size: 12px; color: #909399; font-weight: 400; }
|
||||||
@media print { .el-dialog__footer { display: none; } }
|
.part-row { display: flex; gap: 8px; margin-top: 4px; }
|
||||||
|
.part-item { flex: 1; min-width: 0; }
|
||||||
|
.part-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
|
||||||
|
@media print { .el-dialog__footer, .part-row { display: none; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,6 +57,45 @@ async def list_series(user: CurrentUser, db: DbSession):
|
|||||||
return ok(data=[_brief(t) for t in rows])
|
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):
|
class EstimateBody(BaseModel):
|
||||||
series_id: int
|
series_id: int
|
||||||
width: float = Field(gt=0, description="窗宽 mm")
|
width: float = Field(gt=0, description="窗宽 mm")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class QuoteRequest(BaseModel):
|
|||||||
template_id: int | None = Field(default=None, description="或直接指定样板")
|
template_id: int | None = Field(default=None, description="或直接指定样板")
|
||||||
order_id: int | None = Field(default=None, description="或对已有订单报价")
|
order_id: int | None = Field(default=None, description="或对已有订单报价")
|
||||||
series_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}")
|
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
|
||||||
use_price_book: bool = Field(default=False, description="从产品管理价格本取价(按材料类别均价)")
|
use_price_book: bool = Field(default=False, description="从产品管理价格本取价(按材料类别均价)")
|
||||||
use_scripts: 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:
|
if tpl is not None and tpl.company_id == user.company_id:
|
||||||
w = result.get("width_mm") or 1600
|
w = result.get("width_mm") or 1600
|
||||||
h = result.get("height_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"] = {"id": tpl.id, "name": tpl.name, "series": tpl.series, "wall_thick": tpl.wall_thick}
|
||||||
result["series_estimate"] = {
|
result["series_estimate"] = {
|
||||||
"width": w, "height": h, "total": est["total"], "per_sqm": est["per_sqm"],
|
"width": w, "height": h, "total": est["total"], "per_sqm": est["per_sqm"],
|
||||||
"profile_cost": est["profile_cost"], "glass_cost": est["glass_cost"],
|
"profile_cost": est["profile_cost"], "glass_cost": est["glass_cost"],
|
||||||
"hardware_cost": est["hardware_cost"], "labor_cost": est["labor_cost"],
|
"hardware_cost": est["hardware_cost"], "labor_cost": est["labor_cost"],
|
||||||
"margin_ladder": est["margin_ladder"],
|
"margin_ladder": est["margin_ladder"],
|
||||||
|
"overrides": est.get("overrides"),
|
||||||
}
|
}
|
||||||
return ok(data=result, message="报价成功")
|
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,
|
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)
|
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"]
|
win = data["window"]
|
||||||
base_area = win.get("area") or (win.get("w", 1600) * win.get("h", 1600) / 1e6)
|
base_area = win.get("area") or (win.get("w", 1600) * win.get("h", 1600) / 1e6)
|
||||||
area = width_mm * height_mm / 1e6 * count
|
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,
|
"width": width_mm, "height": height_mm, "count": count,
|
||||||
"sash_count": sash_count, "area": round(area, 4),
|
"sash_count": sash_count, "area": round(area, 4),
|
||||||
"open_area": round(new_sash_area, 4), "fixed_area": round(new_fixed_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
|
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"], sections_total = detail, sections_total + cost
|
||||||
out["profile_cost"] = round(cost, 2)
|
out["profile_cost"] = round(cost, 2)
|
||||||
detail, cost = cost_of(data.get("addon", []))
|
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)
|
out["seal"], out["seal_cost"] = detail, round(cost, 2)
|
||||||
sections_total += cost
|
sections_total += cost
|
||||||
# 玻璃:模板 area 已是该配置块数总面积,按面积比例缩放
|
# 玻璃:模板 area 已是该配置块数总面积,按面积比例缩放
|
||||||
|
ov_glass = ov.get("glass")
|
||||||
glass_detail, glass_cost = [], 0.0
|
glass_detail, glass_cost = [], 0.0
|
||||||
for g in data.get("glass", []):
|
for g in data.get("glass", []):
|
||||||
g_ratio = ratio["open"] if g.get("alloc") == "open" else ratio["fixed"]
|
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_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)})
|
"area": round(g["area"] * g_ratio, 4), "amount": round(amount, 2)})
|
||||||
out["glass"], out["glass_cost"] = glass_detail, round(glass_cost, 2)
|
out["glass"], out["glass_cost"] = glass_detail, round(glass_cost, 2)
|
||||||
sections_total += glass_cost
|
sections_total += glass_cost
|
||||||
detail, cost = cost_of(data.get("extra", []))
|
detail, cost = cost_of(data.get("extra", []))
|
||||||
out["extra"], out["extra_cost"] = detail, round(cost, 2)
|
out["extra"], out["extra_cost"] = detail, round(cost, 2)
|
||||||
sections_total += cost
|
sections_total += cost
|
||||||
|
ov_hw = ov.get("hardware")
|
||||||
hw_detail, hw_cost = [], 0.0
|
hw_detail, hw_cost = [], 0.0
|
||||||
for hw in data.get("hardware", []):
|
for hw in data.get("hardware", []):
|
||||||
qty = sash_count * (hw.get("count") or 1)
|
hw_row = {**hw, **{k: v for k, v in (ov_hw or {}).items() if v is not None}} if ov_hw else hw
|
||||||
amount = qty * hw.get("unit_price", 0)
|
qty = sash_count * (hw_row.get("count") or 1)
|
||||||
|
amount = qty * hw_row.get("unit_price", 0)
|
||||||
hw_cost += amount
|
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)
|
out["hardware"], out["hardware_cost"] = hw_detail, round(hw_cost, 2)
|
||||||
sections_total += hw_cost
|
sections_total += hw_cost
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user