feat: 成本核算模块(鲁铭成本核算Excel全量适配, 价格库580条+75/85/95三系列模板+估算器基准精确还原)

- 解析12表成本核算工作簿: 价格库8表→产品价格本(华健型材模具/丝吉利娅/索宾柯五金/隔热条/胶条/赛遨辅材/螺丝/其他)
- 「铝成本」页→系列成本模板(75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, 八分区JSON)
- 估算器口径对齐核算表: 固定/开启/总面积归因缩放+人工每平实际口径+毛利阶梯10%~70%
- 75系列基准估算total=1118.20与核算表成本合计完全一致(85/95同)
- API /api/costbook: import-excel/series/estimate; 前端 /cost_estimate 页
- 种子 seed_costbook.py; 修复6处解析缺陷; 冒烟60/60+24页巡检0异常+深度审计16/16
This commit is contained in:
HHY
2026-10-05 05:59:08 -07:00
parent 85c51e280c
commit 6ccd36dc3d
17 changed files with 942 additions and 0 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [2026-10-05 05:58] FEAT: v2.36 成本核算模块——鲁铭成本核算 Excel 全量适配。解析 12 表工作簿(铝成本/铝计算/木成本+价格库 8 表): 价格库 580 条(华健型材模具 397/丝吉利娅 207/索宾柯 52/隔热条 42/胶条 56/赛遨 54/螺丝 39/其他 23)导入产品价格本(同名更新/异名新建, material 按 sheet 语义映射 profile/hardware/addon/glass); 「铝成本」页→CostSeriesTemplate 系列成本模板(75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, data JSON 八分区: window/profile/addon/strip/seal/glass/extra/hardware/labor); 估算器口径对齐核算表(固定类料随固定面积/开启类随开启面积/once 按樘数缩放, 人工+其他成本优先用核算表每平实际口径, 毛利阶梯 10%~70%); 75 系列基准窗估算 total=1118.20 与核算表成本合计完全一致(85/95 同样精确还原); API: /api/costbook import-excel/series/estimate CRUD; 前端 /cost_estimate 页(系列卡片+参数估算+八分区明细+毛利阶梯+价格库 Tab+Excel 上传导入); 种子脚本 seed_costbook.py; 修复 6 处解析缺陷(95系列号格式/隔热条total字段/胶条辅材辅件编号列空漏采/玻璃面积重复乘块数/once分支漏加total/基准窗面积等效); 回归: 冒烟 60/60(新增 COST-1~9)+24 页巡检 0 异常+深度审计 16/16 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/costbook.py, src/huamenchuang/api/router.py, src/huamenchuang/models/business.py, src/huamenchuang/models/__init__.py, src/huamenchuang/scripts/seed_costbook.py, src/huamenchuang/scripts/smoke_all.py, frontend/src/views/CostEstimateView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, frontend/scripts/verify-pages.mjs, doc/API.md)
- [2026-09-26 08:30] CHORE: v2.35 系统更名"华门窗"→"定兴门窗"。替换全部用户可见名称: 侧边栏 logo(折叠态 定)/面包屑/登录页标题/分享页 logo 与页脚/页面 title(index.html)/FastAPI title(/docs Swagger)/Dockerfile 注释/各包 docstring; 代码标识符(包名 huamenchuang/数据表/JWT iss)不变; 全仓库 0 处残留, vue-tsc 0 错+构建通过, 无头浏览器验证 logo/面包屑/页面标题生效 (Files: frontend/src/views/LayoutView.vue, frontend/src/views/LoginView.vue, frontend/src/views/ShareView.vue, frontend/index.html, src/huamenchuang/main.py, src/huamenchuang/*/…)__ - [2026-09-26 08:30] CHORE: v2.35 系统更名"华门窗"→"定兴门窗"。替换全部用户可见名称: 侧边栏 logo(折叠态 定)/面包屑/登录页标题/分享页 logo 与页脚/页面 title(index.html)/FastAPI title(/docs Swagger)/Dockerfile 注释/各包 docstring; 代码标识符(包名 huamenchuang/数据表/JWT iss)不变; 全仓库 0 处残留, vue-tsc 0 错+构建通过, 无头浏览器验证 logo/面包屑/页面标题生效 (Files: frontend/src/views/LayoutView.vue, frontend/src/views/LoginView.vue, frontend/src/views/ShareView.vue, frontend/index.html, src/huamenchuang/main.py, src/huamenchuang/*/…)__
- [2026-09-26 08:00] TEST+FIX: v2.34 第二轮深度审计——越权/引用完整性/公开面/持久化 16/16 PASS。新增 scripts/audit_deep.py(直插双公司数据验证 company_id 隔离/跨公司按 ID 直访/登记收款越权/公开接口范围/重启后数据存活)。复查修复 4 处真实缺陷: ①删除有下游单据的客户/合同无校验(会产生孤儿合同/订单, 收款统计 join 缺口)→ 引用完整性拒绝(客户: 合同/量尺/跟进; 合同: 订单/有效收款) ②Service 层误用 self.db(实际会话在 self.repo.db)→AttributeError 500 ③删除路由未捕获 ValueError(保护性失败变 500) ④已完工订单可删(生产留档要求禁删); 公开分享页无头验证渲染正确(标题/明细/合计, 0 JS 异常); 冒烟断言防残留加固(跟进列表 >=1); 深度审计+冒烟 52+16 项全 PASS (Files: src/huamenchuang/services/customer_service.py, src/huamenchuang/services/contract_service.py, src/huamenchuang/services/order_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/orders.py, src/huamenchuang/scripts/audit_deep.py, src/huamenchuang/scripts/smoke_all.py) - [2026-09-26 08:00] TEST+FIX: v2.34 第二轮深度审计——越权/引用完整性/公开面/持久化 16/16 PASS。新增 scripts/audit_deep.py(直插双公司数据验证 company_id 隔离/跨公司按 ID 直访/登记收款越权/公开接口范围/重启后数据存活)。复查修复 4 处真实缺陷: ①删除有下游单据的客户/合同无校验(会产生孤儿合同/订单, 收款统计 join 缺口)→ 引用完整性拒绝(客户: 合同/量尺/跟进; 合同: 订单/有效收款) ②Service 层误用 self.db(实际会话在 self.repo.db)→AttributeError 500 ③删除路由未捕获 ValueError(保护性失败变 500) ④已完工订单可删(生产留档要求禁删); 公开分享页无头验证渲染正确(标题/明细/合计, 0 JS 异常); 冒烟断言防残留加固(跟进列表 >=1); 深度审计+冒烟 52+16 项全 PASS (Files: src/huamenchuang/services/customer_service.py, src/huamenchuang/services/contract_service.py, src/huamenchuang/services/order_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/orders.py, src/huamenchuang/scripts/audit_deep.py, src/huamenchuang/scripts/smoke_all.py)
+34
View File
@@ -521,3 +521,37 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
| **领料出库** | POST | `/api/warehouse/pickout` | `{contract_id, note?}` | 对齐 pickout:按领料清单扣库存+流水;库存不足报缺口 | | **领料出库** | POST | `/api/warehouse/pickout` | `{contract_id, note?}` | 对齐 pickout:按领料清单扣库存+流水;库存不足报缺口 |
| **采购单** | POST | `/api/purchases` | `{contract_id?(自动读缺料), items?, note?}` | 缺料清单自动生成(CG+日期+流水) | | **采购单** | POST | `/api/purchases` | `{contract_id?(自动读缺料), items?, note?}` | 缺料清单自动生成(CG+日期+流水) |
| 采购列表/入库 | GET/POST | `/api/purchases[?status]` · `/{id}/receive` | — | 入库联动:明细写入库存+StockMove 流水 | | 采购列表/入库 | GET/POST | `/api/purchases[?status]` · `/{id}/receive` | — | 入库联动:明细写入库存+StockMove 流水 |
### 12.27 成本核算模块 `/api/costbook`(v2.36 新增,鲁铭成本核算 Excel 适配)
> 适配门窗厂成本核算工作簿(12 sheet 格式:铝成本/铝计算/木成本 + 价格库 8 表 + 其他)。
> 价格库 8 表(华健开模汇总/丝吉利娅五金/索宾柯/隔热条/胶条/赛遨辅材/螺丝/其他价格)全量导入产品价格本 `/api/products`(同名同材质更新价格,否则新建,material 按 sheet 语义映射);
> 「铝成本」页解析为**系列成本模板** `cost_series_templates`(data JSON 含 window/profile/addon/strip/seal/glass/extra/hardware/labor 八分区)。
> 种子:`uv run python -m huamenchuang.scripts.seed_costbook`(data/ 下三个鲁铭 xlsx,75/85/95 三系列已预置)。
| 接口名称 | 方式 | URL 路径 | 请求参数 | 响应示例 |
|---|---|---|---|---|
| 导入核算 Excel | POST | `/api/costbook/import-excel` | multipart `file`(.xlsx) | `{libraries:{created,updated}, optimize_stats:{bars,total_length,sashes,glasses,glass_area}}`,message 带统计 |
| 系列模板列表 | GET | `/api/costbook/series` | — | `[{id,name,series,wall_thick,source_file,enabled,window:{w,h,area,sash_area,...}}]` |
| 系列模板详情 | GET | `/api/costbook/series/{id}` | — | `{...brief, data:{window,profile[],addon[],strip[],seal[],glass[],extra[],hardware[],labor}}` |
| 新建系列模板 | POST | `/api/costbook/series` | `{name,series,wall_thick,data}` | `{id}` |
| 删除系列模板 | DELETE | `/api/costbook/series/{id}` | — | `{code:1}` |
| **成本估算** | POST | `/api/costbook/estimate` | `{series_id,width,height,count=1,sash_count?}` | 见下 |
**估算响应**(口径对齐核算表:固定类料随固定面积、开启类随开启面积、once 类按樘数缩放;人工/其他成本优先用核算表每平实际口径):
```json
{
"series": 75, "wall_thick": 1.4, "name": "75框扇齐平",
"width": 1600, "height": 1600, "count": 1, "sash_count": 1,
"area": 2.56, "open_area": 0.78, "fixed_area": 1.78,
"profile": [{"code","name","qty(m)","weight","weight_per_m","unit_price","amount"}],
"profile_cost": 466.8, "addon_cost": 14.11, "strip_cost": 17.69, "seal_cost": 27,
"glass_cost": 179.2, "extra_cost": 0, "hardware_cost": 55,
"labor_cost": 243.2, "other_cost": 115.2,
"total": 1118.2, "per_sqm": 436.8,
"margin_ladder": {"10%": 1230.02, "20%": 1341.84, "...": "50%=1677.29", "70%": 1900.93}
}
```
校验基准:75 系列基准窗(1600×1600,开启 1 扇)估算 total=1118.20,与核算表"成本合计"完全一致(冒烟 COST-1~9)。
+1
View File
@@ -33,6 +33,7 @@ const PAGES = [
['printer', '/printer'], ['printer', '/printer'],
['warehouse', '/warehouse'], ['warehouse', '/warehouse'],
['products', '/products'], ['products', '/products'],
['cost_estimate', '/cost_estimate'],
['devices', '/devices'], ['devices', '/devices'],
['market', '/market'], ['market', '/market'],
['chart', '/chart'], ['chart', '/chart'],
+1
View File
@@ -24,6 +24,7 @@ const router = createRouter({
{ path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } }, { path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } },
{ path: 'combine', component: () => import('../views/CombineView.vue'), meta: { title: '样板组合' } }, { path: 'combine', component: () => import('../views/CombineView.vue'), meta: { title: '样板组合' } },
{ path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } }, { path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } },
{ path: 'cost_estimate', component: () => import('../views/CostEstimateView.vue'), meta: { title: '成本核算' } },
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } }, { path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } }, { path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
{ path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } }, { path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } },
+223
View File
@@ -0,0 +1,223 @@
<template>
<el-card>
<el-tabs v-model="tab">
<!-- ============ 成本估算 ============ -->
<el-tab-pane label="成本估算" name="estimate">
<el-row :gutter="14">
<el-col :span="7">
<el-card shadow="never" header="系列成本模板">
<div class="series-list" v-loading="loading">
<div v-for="s in series" :key="s.id" class="series-item"
:class="{ active: current?.id === s.id }" @click="pick(s)">
<div class="series-name">{{ s.name }}</div>
<div class="series-meta">系列 {{ s.series }} · 壁厚 {{ s.wall_thick }}mm
<el-tag v-if="s.source_file" size="small" type="info" effect="plain">Excel</el-tag>
</div>
</div>
<el-empty v-if="!series.length && !loading" description="暂无系列模板,请先导入核算 Excel" :image-size="70" />
</div>
<el-upload :show-file-list="false" :before-upload="onImport" accept=".xlsx" style="margin-top:12px">
<el-button type="primary" plain style="width:100%" :loading="importing">导入成本核算 Excel</el-button>
</el-upload>
<div class="tip">支持鲁铭 12 表格式工作簿:价格库 8 表自动入产品价格本,铝成本页生成系列模板。</div>
</el-card>
</el-col>
<el-col :span="17">
<el-card shadow="never" header="估算参数">
<el-form inline>
<el-form-item label="窗宽 mm"><el-input-number v-model="form.width" :min="300" :max="6000" :step="100" /></el-form-item>
<el-form-item label="窗高 mm"><el-input-number v-model="form.height" :min="300" :max="3000" :step="100" /></el-form-item>
<el-form-item label="樘数"><el-input-number v-model="form.count" :min="1" :max="999" /></el-form-item>
<el-form-item label="开启扇数"><el-input-number v-model="form.sash_count" :min="0" :max="20" /></el-form-item>
<el-form-item><el-button type="primary" :disabled="!current" @click="run">估算成本</el-button></el-form-item>
</el-form>
</el-card>
<template v-if="result">
<el-row :gutter="10" class="stat-row">
<el-col :span="6"><el-card shadow="never"><el-statistic title="总成本(元)" :value="result.total" :precision="2" /></el-card></el-col>
<el-col :span="6"><el-card shadow="never"><el-statistic title="每平方成本(元/㎡)" :value="result.per_sqm" :precision="2" /></el-card></el-col>
<el-col :span="6"><el-card shadow="never"><el-statistic title="总面积(㎡)" :value="result.area" :precision="2" /></el-card></el-col>
<el-col :span="6"><el-card shadow="never"><el-statistic title="建议售价(50%毛利)" :value="result.margin_ladder['50%']" :precision="2" /></el-card></el-col>
</el-row>
<el-card shadow="never" header="用料与成本明细" class="detail-card">
<el-collapse v-model="opened">
<el-collapse-item v-for="sec in sections" :key="sec.key" :name="sec.key">
<template #title>
<span class="sec-title">{{ sec.label }}</span>
<el-tag size="small" :type="sec.key === 'total' ? 'danger' : 'info'" effect="plain">
{{ sec.key === 'total' ? result.total : result[sec.key + '_cost'] }} 元
</el-tag>
</template>
<el-table :data="result[sec.key]" size="small" v-if="result[sec.key]?.length">
<el-table-column v-for="col in sec.cols" :key="col.k" :prop="col.k" :label="col.l" :width="col.w" />
</el-table>
<el-empty v-else description="该系列无此项" :image-size="48" />
</el-collapse-item>
</el-collapse>
<el-table :data="ladderRows" size="small" class="ladder">
<el-table-column v-for="r in ladderRows" :key="r.k" :prop="r.k" :label="r.l" align="center" />
</el-table>
</el-card>
</template>
<el-empty v-else description="选择系列后点击「估算成本」" />
</el-col>
</el-row>
</el-tab-pane>
<!-- ============ 价格库 ============ -->
<el-tab-pane label="价格库" name="lib">
<div class="toolbar">
<el-input v-model="lib.keyword" placeholder="名称/规格" clearable style="width:180px" @keyup.enter="loadLib" />
<el-select v-model="lib.material" placeholder="全部类型" clearable style="width:120px" @change="loadLib">
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" />
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-select>
<el-button type="primary" @click="loadLib">查询</el-button>
<span class="tip">共 {{ lib.total }} 条 · 来自核算 Excel 价格库 8 表(华健型材模具/丝吉利娅/索宾柯五金/隔热条/胶条/赛遨辅材/螺丝/其他)</span>
</div>
<el-table :data="lib.rows" v-loading="lib.loading" stripe size="small">
<el-table-column prop="name" label="名称" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" width="75">
<template #default="{ row }">{{ libLabel(row.material) }}</template>
</el-table-column>
<el-table-column prop="spec" label="规格" width="110" />
<el-table-column prop="unit" label="单位" width="70" />
<el-table-column prop="price" label="单价" width="90" />
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip />
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
:total="lib.total" :page-size="lib.page_size" v-model:current-page="lib.page" @current-change="loadLib" />
</el-tab-pane>
</el-tabs>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api, apiUpload } from '../api'
const tab = ref('estimate')
const series = ref<any[]>([])
const current = ref<any>(null)
const loading = ref(false)
const importing = ref(false)
const result = ref<any>(null)
const opened = ref(['profile', 'glass', 'hardware'])
const form = reactive({ width: 1600, height: 1600, count: 1, sash_count: 0 })
const sections = [
{ key: 'profile', label: '铝型材成本', cols: [
{ k: 'code', l: '型材编号', w: 120 }, { k: 'name', l: '名称', w: 110 },
{ k: 'qty', l: '使用数量(m)', w: 100 }, { k: 'weight_per_m', l: '米重(kg/m)', w: 95 },
{ k: 'weight', l: '总重量(kg)', w: 95 }, { k: 'unit_price', l: '单价(元/kg)', w: 95 },
{ k: 'amount', l: '价格', w: 85 }] },
{ key: 'addon', label: '型材辅件成本', cols: [
{ k: 'name', l: '名称', w: 130 }, { k: 'qty', l: '使用数量(m)', w: 100 },
{ k: 'weight', l: '总重量(kg)', w: 95 }, { k: 'unit_price', l: '单价(元/kg)', w: 95 },
{ k: 'amount', l: '价格', w: 85 }] },
{ key: 'strip', label: '隔热条成本', cols: [
{ k: 'code', l: '编号', w: 120 }, { k: 'total', l: '合计(米)', w: 85 },
{ k: 'unit_price', l: '单价(元/米)', w: 100 }, { k: 'amount', l: '价格', w: 85 }] },
{ key: 'seal', label: '胶条成本', cols: [
{ k: 'name', l: '胶条名称', w: 150 }, { k: 'qty', l: '使用米数', w: 90 },
{ k: 'unit_price', l: '单价(元/m)', w: 95 }, { k: 'amount', l: '价格', w: 85 }] },
{ key: 'glass', label: '玻璃成本', cols: [
{ k: 'blocks', l: '玻璃数量', w: 90 }, { k: 'config', l: '玻璃配置', w: 190 },
{ k: 'area', l: '玻璃面积(㎡)', w: 110 }, { k: 'unit_price', l: '单价(元/㎡)', w: 95 },
{ k: 'amount', l: '价格', w: 85 }] },
{ key: 'extra', label: '辅材成本', cols: [
{ k: 'name', l: '名称', w: 150 }, { k: 'qty', l: '数量', w: 80 },
{ k: 'unit_price', l: '单价', w: 85 }, { k: 'amount', l: '价格', w: 85 }] },
{ key: 'hardware', label: '五金成本', cols: [
{ k: 'vendor', l: '五金厂家', w: 110 }, { k: 'name', l: '名称', w: 200 },
{ k: 'qty', l: '数量', w: 80 }, { k: 'unit_price', l: '单价', w: 85 },
{ k: 'amount', l: '价格', w: 85 }] },
]
const ladderRows = computed(() => {
if (!result.value) return []
const ladder = result.value.margin_ladder || {}
const keys = Object.keys(ladder)
const row1: Record<string, any> = { k: '毛利', l: '毛利阶梯' }
const row2: Record<string, any> = { k: '售价', l: '建议售价' }
keys.forEach(k => { row1[k] = k.replace('%', ''); row2[k] = ladder[k] })
return [row1, row2]
})
async function load() {
loading.value = true
try {
series.value = await api('GET', '/api/costbook/series')
if (series.value.length && !current.value) pick(series.value[0])
} finally { loading.value = false }
}
function pick(s: any) {
current.value = s
result.value = null
const win = s.window || s.data?.window
if (win) {
form.width = win.w || 1600
form.height = win.h || 1600
// 开启扇数:按基准开启面积/玻璃块数推 1,无开启信息则 0
form.sash_count = win.sash_area ? 1 : 0
}
}
async function run() {
if (!current.value) return
result.value = await api('POST', '/api/costbook/estimate', {
series_id: current.value.id, width: form.width, height: form.height,
count: form.count, sash_count: form.sash_count,
})
}
async function onImport(file: File) {
importing.value = true
try {
const d = await apiUpload<any>('/api/costbook/import-excel', file)
ElMessage.success(d.message || '导入完成')
current.value = null
await load()
await loadLib()
} finally { importing.value = false }
return false
}
const lib = reactive({ rows: [], total: 0, page: 1, page_size: 15, keyword: '', material: '', loading: false })
async function loadLib() {
lib.loading = true
try {
const d = await api<any>('GET', '/api/products', undefined, {
params: { page: lib.page, page_size: lib.page_size, keyword: lib.keyword, material: lib.material || undefined },
})
lib.rows = d.items; lib.total = d.total
} finally { lib.loading = false }
}
function libLabel(m: string) {
return ({ profile: '型材', glass: '玻璃', hardware: '五金', addon: '配件', subbar: '副梃' } as any)[m] || m
}
onMounted(() => { load(); loadLib() })
</script>
<style scoped>
.series-list { min-height: 120px; }
.series-item { padding: 10px 12px; border: 1px solid var(--el-border-color-light); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: all .15s; }
.series-item:hover { border-color: var(--el-color-primary-light-5); }
.series-item.active { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.series-name { font-weight: 600; }
.series-meta { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 2px; }
.tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 8px; line-height: 1.5; }
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.stat-row { margin: 12px 0; }
.detail-card { margin-top: 4px; }
.sec-title { font-weight: 600; margin-right: 10px; }
.ladder { margin-top: 14px; }
</style>
+1
View File
@@ -34,6 +34,7 @@
<el-menu-item index="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item> <el-menu-item index="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item>
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item> <el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item> <el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
<el-menu-item index="/cost_estimate"><el-icon><Coin /></el-icon>成本核算</el-menu-item>
<el-menu-item index="/devices"><el-icon><Cpu /></el-icon>设备管理</el-menu-item> <el-menu-item index="/devices"><el-icon><Cpu /></el-icon>设备管理</el-menu-item>
</el-menu-item-group> </el-menu-item-group>
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+107
View File
@@ -0,0 +1,107 @@
"""成本核算路由:系列成本模板(取自成本核算 Excel)+ 价格库导入 + 成本估算。"""
import json
from fastapi import APIRouter, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.business import CostSeriesTemplate
from huamenchuang.services import costbook_service as svc
router = APIRouter(tags=["成本核算"])
def _brief(t: CostSeriesTemplate) -> dict:
data = json.loads(t.data)
return {"id": t.id, "name": t.name, "series": t.series, "wall_thick": t.wall_thick,
"source_file": t.source_file, "enabled": t.enabled, "window": data.get("window")}
def _full(t: CostSeriesTemplate) -> dict:
return {**_brief(t), "data": json.loads(t.data)}
@router.post("/costbook/import-excel")
async def import_excel(file: UploadFile, user: CurrentUser, db: DbSession):
"""上传成本核算 Excel(12 sheet 鲁铭格式):价格库 8 表入产品价格本,铝成本页入系列模板。"""
blob = await file.read()
if not blob:
return fail("文件为空")
import io
from openpyxl import load_workbook
try:
wb = load_workbook(io.BytesIO(blob), data_only=True)
parsed = {"file": file.filename or "upload.xlsx",
"series_summary": svc._parse_series_summary(wb["铝成本"]),
"libraries": {sn: svc._parse_library(wb[sn])
for sn in svc.LIB_SHEETS if sn in wb.sheetnames},
"optimize_stats": svc._parse_optimize_stats(wb["铝计算"]) if "铝计算" in wb.sheetnames else {}}
except KeyError as e:
return fail(f"缺少必需工作表 {e},请确认上传成本核算格式工作簿")
except Exception as e: # noqa: BLE001
return fail(f"解析失败:{e}")
lib_stats = await svc.import_libraries(db, user.company_id, parsed["libraries"])
await svc.import_series(db, user.company_id, parsed)
return ok(data={"libraries": lib_stats, "optimize_stats": parsed["optimize_stats"]},
message=f"导入完成:价格库新增 {lib_stats['created']} / 更新 {lib_stats['updated']} 条")
@router.get("/costbook/series")
async def list_series(user: CurrentUser, db: DbSession):
rows = list((await db.execute(select(CostSeriesTemplate).where(
CostSeriesTemplate.company_id == user.company_id)
.order_by(CostSeriesTemplate.series, CostSeriesTemplate.id))).scalars())
return ok(data=[_brief(t) for t in rows])
class EstimateBody(BaseModel):
series_id: int
width: float = Field(gt=0, description="窗宽 mm")
height: float = Field(gt=0, description="窗高 mm")
count: int = Field(default=1, ge=1, description="樘数")
sash_count: int | None = Field(default=None, ge=0, description="开启扇数,缺省按基准开启率")
@router.post("/costbook/estimate")
async def estimate(body: EstimateBody, user: CurrentUser, db: DbSession):
t = await db.get(CostSeriesTemplate, body.series_id)
if t is None or t.company_id != user.company_id:
return fail("系列模板不存在")
return ok(data=svc.estimate(t, body.width, body.height, body.count, body.sash_count))
@router.get("/costbook/series/{sid}")
async def get_series(sid: int, user: CurrentUser, db: DbSession):
t = await db.get(CostSeriesTemplate, sid)
if t is None or t.company_id != user.company_id:
return fail("系列模板不存在")
return ok(data=_full(t))
class SeriesBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
series: int = Field(ge=0)
wall_thick: float = Field(gt=0)
data: dict
@router.post("/costbook/series")
async def create_series(body: SeriesBody, user: CurrentUser, db: DbSession):
t = CostSeriesTemplate(company_id=user.company_id, name=body.name, series=body.series,
wall_thick=body.wall_thick,
data=json.dumps(body.data, ensure_ascii=False))
db.add(t)
await db.flush()
return ok(data={"id": t.id}, message="系列模板已创建")
@router.delete("/costbook/series/{sid}")
async def delete_series(sid: int, user: CurrentUser, db: DbSession):
t = await db.get(CostSeriesTemplate, sid)
if t is None or t.company_id != user.company_id:
return fail("系列模板不存在")
await db.delete(t)
return ok(message="系列模板已删除")
+2
View File
@@ -6,6 +6,7 @@ from huamenchuang.api import (
auth, auth,
business, business,
contracts, contracts,
costbook,
customers, customers,
finance, finance,
optimize, optimize,
@@ -45,6 +46,7 @@ api_router.include_router(salesmen.router)
api_router.include_router(purchases.router) api_router.include_router(purchases.router)
api_router.include_router(warehouse.router) api_router.include_router(warehouse.router)
api_router.include_router(business.router) api_router.include_router(business.router)
api_router.include_router(costbook.router)
api_router.include_router(platform.router) api_router.include_router(platform.router)
api_router.include_router(reports.router) api_router.include_router(reports.router)
api_router.include_router(platform.router) api_router.include_router(platform.router)
+2
View File
@@ -3,6 +3,7 @@ from huamenchuang.models.announcement import Announcement
from huamenchuang.models.business import ( from huamenchuang.models.business import (
AfterSale, AfterSale,
ContractReceipt, ContractReceipt,
CostSeriesTemplate,
CustomerFollow, CustomerFollow,
MeasureOrder, MeasureOrder,
PriceScript, PriceScript,
@@ -42,6 +43,7 @@ __all__ = [
"CompanyParam", "CompanyParam",
"Contract", "Contract",
"ContractReceipt", "ContractReceipt",
"CostSeriesTemplate",
"Customer", "Customer",
"CustomerFollow", "CustomerFollow",
"DEFAULT_PROCESSES", "DEFAULT_PROCESSES",
+35
View File
@@ -127,6 +127,41 @@ class ProductCatelog(Base):
company_id: Mapped[int] = mapped_column(default=1, index=True) company_id: Mapped[int] = mapped_column(default=1, index=True)
class CostSeriesTemplate(Base):
"""成本核算系列模板:某型材系列标准窗的全量用料+价格参数,取自成本核算 Excel。
data JSON 结构(对齐核算表"铝成本"页分区):
{
"window": {"w":1600,"h":1600,"area":2.56,"open_area":0.78,"fixed_area":1.78,
"sash_area":0.78,"sash_perimeter":4,"glass_count":3},
"profile": [{"code","name","length","weight_per_m","unit_price","alloc"}],
"addon": [{"code","name","length","weight_per_m","unit_price","alloc"}], # 角码/连接件
"strip": [{"code","frame","sash","mullion","splice","total","unit_price","alloc"}], # 隔热条
"seal": [{"code","name","length","unit_price","alloc"}], # 胶条
"glass": [{"count","config","area","unit_price","alloc"}],
"extra": [{"name","spec","count","unit_price","alloc"}], # 辅材(外购纱窗等)
"hardware": [{"vendor","name","count","unit_price"}],
"labor": {"install":60,"w14":35,"w18":60,"sales":30,"fuel":10,"ins":5}
}
alloc: fixed=随固定面积缩放 / open=随开启面积缩放 / both=随总面积缩放 / once=按樘数
"""
__tablename__ = "cost_series_templates"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
name: Mapped[str] = mapped_column(String(64))
series: Mapped[int] = mapped_column(default=0) # 门窗系列 75/85/95
wall_thick: Mapped[float] = mapped_column(default=1.4) # 壁厚
source_file: Mapped[str] = mapped_column(String(255), default="")
data: Mapped[str] = mapped_column(Text, default="{}")
enabled: Mapped[int] = mapped_column(default=1)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)
class Quotation(Base): class Quotation(Base):
"""报价单:画布算料结果 + 手动调整,归属客户/合同。""" """报价单:画布算料结果 + 手动调整,归属客户/合同。"""
+51
View File
@@ -0,0 +1,51 @@
"""成本核算数据种子:解析 data/ 下三个鲁铭系列成本核算 Excel,全量导入价格库与系列模板。
用法: uv run python -m huamenchuang.scripts.seed_costbook [--company 1]
"""
import asyncio
from pathlib import Path
DATA = Path(__file__).resolve().parents[3] / "data"
FILES = [
"3.2成本核算鲁铭75框扇齐平系列1.4.xlsx",
"3.2成本核算鲁铭85框扇齐平系列1.8.xlsx",
"2026.7.28成本核算鲁铭95窗纱一体系列1.8.xlsx",
]
async def seed(company_id: int = 1) -> None:
import huamenchuang.models # noqa: F401
from huamenchuang.core.database import SessionLocal, init_db
from huamenchuang.services import costbook_service as svc
await init_db()
async with SessionLocal() as db:
lib_stats = {"created": 0, "updated": 0}
series_ids = []
for fn in FILES:
path = DATA / fn
if not path.exists():
print(f"跳过(不存在): {fn}")
continue
parsed = svc.parse_costbook(path)
stats = await svc.import_libraries(db, company_id, parsed["libraries"])
tid = await svc.import_series(db, company_id, parsed)
series_ids.append((tid, parsed["series_summary"]["window"].get("series"),
parsed["file"]))
lib_stats["created"] += stats["created"]
lib_stats["updated"] += stats["updated"]
opt = parsed["optimize_stats"]
print(f"[{fn}] 系列{parsed['series_summary']['window'].get('series')} "
f"型材{len(parsed['series_summary']['profile'])}行 优化单{opt.get('bars', 0)}支")
await db.commit()
print(f"价格库: 新增 {lib_stats['created']} / 更新 {lib_stats['updated']}")
print(f"系列模板: {series_ids}")
if __name__ == "__main__":
import sys
company = 1
if "--company" in sys.argv:
company = int(sys.argv[sys.argv.index("--company") + 1])
asyncio.run(seed(company))
+27
View File
@@ -202,6 +202,33 @@ def main():
d = call("POST", f"/api/purchases/{poid}/receive") d = call("POST", f"/api/purchases/{poid}/receive")
check("P1-extra 采购入库联动", d.get("code") == 1) check("P1-extra 采购入库联动", d.get("code") == 1)
# ========== 成本核算(v2.36 鲁铭 Excel 适配) ==========
d = call("GET", "/api/costbook/series")
check("COST-1 系列模板列表", d.get("code") == 1 and len(d["data"]) >= 3)
sid75 = next((s["id"] for s in d.get("data", []) if s["series"] == 75), None)
check("COST-2 75系列模板存在", bool(sid75))
d = call("POST", "/api/costbook/estimate", {"series_id": sid75, "width": 1600, "height": 1600, "count": 1})
est = d.get("data", {})
check("COST-3 75基准估算=1118.20", d.get("code") == 1 and abs(est.get("total", 0) - 1118.20) < 1.5,
f"got={est.get('total')}")
check("COST-4 分区成本齐全", all(k in est for k in
("profile_cost", "addon_cost", "strip_cost", "seal_cost", "glass_cost", "hardware_cost",
"labor_cost", "other_cost")))
check("COST-5 毛利阶梯50%", abs(est.get("margin_ladder", {}).get("50%", 0) - 1677.29) < 1.5)
d = call("POST", "/api/costbook/estimate", {"series_id": sid75, "width": 2000, "height": 1800, "count": 2})
check("COST-6 任意尺寸估算", d.get("code") == 1 and d["data"]["total"] > 0 and d["data"]["count"] == 2)
d = call("GET", "/api/products?page=1&page_size=5&keyword=隔热条")
check("COST-7 价格库(隔热条)入库", d.get("code") == 1 and d["data"]["total"] > 0)
d = call("GET", "/api/products?page=1&page_size=1")
check("COST-8 价格库总量>500", d.get("code") == 1 and d["data"]["total"] > 500,
f"total={d.get('data', {}).get('total')}")
# 越权:B 公司无模板(若 B 公司账号存在)
rb = httpx.post(f"{BASE}/api/auth/login", json={"phone_number": "13900000001", "password": "123456"}, timeout=15)
if rb.status_code == 200 and rb.json().get("code") == 1:
HB = {"Authorization": f"Bearer {rb.json()['data']['token']}"}
d = httpx.get(f"{BASE}/api/costbook/series", headers=HB, timeout=15).json()
check("COST-9 公司隔离(B公司无模板)", d.get("code") == 1 and len(d["data"]) == 0)
# ========== 汇总 ========== # ========== 汇总 ==========
print(f"\n===== 冒烟结果 {sum(1 for _, r in results if r == 'PASS')}/{len(results)} PASS =====") print(f"\n===== 冒烟结果 {sum(1 for _, r in results if r == 'PASS')}/{len(results)} PASS =====")
for name, r in results: for name, r in results:
@@ -0,0 +1,457 @@
"""成本核算工作簿解析与适配服务。
解析门窗厂成本核算 Excel(鲁铭系列格式,12 个 sheet):
- 价格库 8 sheet(三文件共享)→ Product 产品价格本
- 「铝成本」系列核算页 → CostSeriesTemplate 系列成本模板
- 「铝计算」型材优化单 → 汇总统计(记录核对用)
估算器 estimate() 复刻核算表口径:
- 固定类料随固定面积缩放、开启类料随开启面积缩放、总面积类随面积缩放
- 人工安装制作 = (安装费 + 对应壁厚人工费) × 总面积
- 销售及其他 = (销售提成 + 加油 + 保险) × 总面积
- 毛利阶梯 = 成本合计 × (1 + 10%..70%)
"""
import json
import re
from pathlib import Path
from openpyxl import load_workbook
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from huamenchuang.models.business import CostSeriesTemplate, Product
# ---------------- sheet 常量 ----------------
LIB_SHEETS = [
"华健开模汇总表", "丝吉利娅五金价格", "索宾柯价格", "隔热条价格",
"胶条价格", "赛遨价格", "螺丝价格", "其他价格",
]
def _num(v) -> float:
"""单元格转 float,失败返回 0。"""
if v is None:
return 0.0
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).strip())
except ValueError:
return 0.0
def _txt(v) -> str:
return str(v).strip() if v is not None else ""
def _col(ws, letter: str, row: int):
return ws[f"{letter}{row}"].value
# ---------------- 解析 ----------------
def parse_costbook(path: str | Path) -> dict:
"""全量解析一个成本核算工作簿。"""
wb = load_workbook(path, data_only=True)
return {
"file": Path(path).name,
"series_summary": _parse_series_summary(wb["铝成本"]),
"libraries": {sn: _parse_library(wb[sn]) for sn in LIB_SHEETS if sn in wb.sheetnames},
"optimize_stats": _parse_optimize_stats(wb["铝计算"]) if "铝计算" in wb.sheetnames else {},
}
def _parse_series_summary(ws) -> dict:
"""解析「铝成本」页 → 系列标准成本模板 data JSON。"""
header_row = next((r for r in range(17, 20) if _txt(_col(ws, "A", r)) == "铝型材成本"), None)
if header_row is None:
raise ValueError("未找到「铝型材成本」分区表头")
summary: dict = {"window": _parse_window_header(ws), "labor": _parse_labor(ws)}
# 各分区起始行
starts = {}
for r in range(header_row, ws.max_row + 1):
t = _txt(_col(ws, "A", r))
if t.endswith("成本") and len(t) <= 8:
starts[t] = r
elif t == "辅材成本":
starts[t] = r
def rows_of(start: int, end: int, col: str = "A") -> list[int]:
"""收集分区内数据行:按 col 列非空判定,跳过合计行。"""
return [r for r in range(start + 2, end)
if _txt(_col(ws, col, r)) and _txt(_col(ws, col, r)) != "合计:"]
bounds = sorted(starts.items(), key=lambda kv: kv[1]) + [(None, ws.max_row + 1)]
def section_range(name: str) -> tuple[int, int]:
for i, (k, v) in enumerate(bounds):
if k == name:
return v, bounds[i + 1][1]
return 0, 0
s, e = section_range("铝型材成本")
summary["profile"] = [{"code": _txt(_col(ws, "A", r)), "name": _txt(_col(ws, "B", r)),
"length": _num(_col(ws, "D", r)), "weight_per_m": _num(_col(ws, "E", r)),
"unit_price": _num(_col(ws, "G", r)), "alloc": _alloc(_txt(_col(ws, "B", r)))}
for r in rows_of(s, e)]
s, e = section_range("型材辅件成本")
summary["addon"] = [{"code": _txt(_col(ws, "A", r)), "name": _txt(_col(ws, "B", r)),
"length": _num(_col(ws, "D", r)), "weight_per_m": _num(_col(ws, "E", r)),
"unit_price": _num(_col(ws, "G", r)), "alloc": _alloc(_txt(_col(ws, "B", r)))}
for r in rows_of(s, e, "B")]
s, e = section_range("隔热条成本")
summary["strip"] = []
for r in rows_of(s, e):
total = _num(_col(ws, "F", r)) or (_num(_col(ws, "B", r)) + _num(_col(ws, "C", r))
+ _num(_col(ws, "D", r)) + _num(_col(ws, "E", r)))
if not total:
continue
alloc = "open" if (_num(_col(ws, "C", r)) and not _num(_col(ws, "B", r))) else \
("both" if _num(_col(ws, "C", r)) else "fixed")
summary["strip"].append({"code": _txt(_col(ws, "A", r)), "frame": _num(_col(ws, "B", r)),
"sash": _num(_col(ws, "C", r)), "mullion": _num(_col(ws, "D", r)),
"splice": _num(_col(ws, "E", r)), "total": total,
"unit_price": _num(_col(ws, "G", r)), "alloc": alloc})
s, e = section_range("胶条成本")
summary["seal"] = [{"code": _txt(_col(ws, "A", r)), "name": _txt(_col(ws, "C", r)),
"length": _num(_col(ws, "F", r)), "unit_price": _num(_col(ws, "G", r)),
"alloc": _alloc(_txt(_col(ws, "C", r)))} for r in rows_of(s, e, "C")]
s, e = section_range("玻璃成本")
summary["glass"] = []
for r in rows_of(s, e, "B"):
cfg = _txt(_col(ws, "B", r))
if "+" not in cfg:
continue
summary["glass"].append({"count": _num(_col(ws, "A", r)), "config": cfg,
"area": _num(_col(ws, "E", r)), "unit_price": _num(_col(ws, "G", r)),
"alloc": "open" if "开启" in cfg else "fixed"})
s, e = section_range("辅材成本")
summary["extra"] = [{"name": _txt(_col(ws, "C", r)) or _txt(_col(ws, "A", r)),
"spec": _txt(_col(ws, "E", r)), "count": _num(_col(ws, "F", r)),
"unit_price": _num(_col(ws, "G", r)), "alloc": "once"}
for r in rows_of(s, e, "C")]
s, e = section_range("五金成本")
summary["hardware"] = [{"vendor": _txt(_col(ws, "A", r)), "code": _txt(_col(ws, "B", r)),
"name": _txt(_col(ws, "D", r)), "count": _num(_col(ws, "F", r)),
"unit_price": _num(_col(ws, "G", r)), "alloc": "open"}
for r in rows_of(s, e, "D")]
# 成本汇总区的人工/其他成本 → 每平单价(核算表实际口径,优先于参数推导)
area = summary["window"].get("area") or 0
for r in range(1, header_row):
label = _txt(_col(ws, "F", r))
if label == "人工安装制作成本:" and area:
summary["labor"]["rate_labor"] = round(_num(_col(ws, "G", r)) / area, 4)
elif label == "销售+其他成本:" and area:
summary["labor"]["rate_other"] = round(_num(_col(ws, "G", r)) / area, 4)
return summary
def _parse_window_header(ws) -> dict:
"""解析表头区(1~15 行)的窗型参数。"""
win: dict = {}
for r in range(1, 16):
for c in range(1, ws.max_column + 1):
v = ws.cell(r, c).value
if not isinstance(v, str):
continue
nxt = ws.cell(r, c + 1).value
mapping = {"门窗数量(樘):": "count", "总面积(㎡):": "area", "平均面积(㎡):": "avg_area",
"开启率:": "open_rate", "开启扇面积:": "sash_area",
"开启扇周长:": "sash_perimeter", "玻璃块数:": "glass_count"}
if v in mapping and nxt is not None:
win[mapping[v]] = _num(nxt)
order_name = _txt(_col(ws, "B", 2))
# 兼容「75系列」「95窗纱一体系列」两种写法:系列字向前找最近数字
m = re.search(r"(\d+(?:\.\d+)?)(?:窗纱一体|框扇齐平|平开|推拉)?系列", order_name)
if not m:
m = re.search(r"(\d+(?:\.\d+)?)", order_name)
if m:
win["series"] = float(m.group(1))
m2 = re.search(r"(\d+(?:\.\d+)?)壁厚", _txt(_col(ws, "B", 2)))
if m2:
win["wall_thick"] = float(m2.group(1))
# 基准窗宽高:Excel 未给尺寸时按基准面积取等效正窗(各料均按面积比例缩放,宽高比不影响口径)
if "area" in win and "w" not in win:
side = round((win["area"] ** 0.5) * 1000)
win["w"], win["h"] = side, side
win.setdefault("open_area", win.get("sash_area", 0))
win.setdefault("fixed_area", round(win.get("area", 0) - win.get("sash_area", 0), 4))
return win
def _parse_labor(ws) -> dict:
"""解析人工费参数区(K/L 列)。"""
labor = {"install": 0, "w14": 0, "w18": 0, "sales": 0, "fuel": 0, "ins": 0}
keymap = {"安装费:": "install", "1.4壁厚人工费:": "w14", "1.8壁厚人工费:": "w18",
"销售:": "sales", "加油:": "fuel", "保险:": "ins"}
for r in range(1, 20):
for c in range(1, ws.max_column):
v = ws.cell(r, c).value
if isinstance(v, str) and v in keymap:
labor[keymap[v]] = _num(ws.cell(r, c + 1).value)
return labor
def _alloc(name: str) -> str:
"""按名称推断用料归因(fixed/open/both)。"""
if "扇" in name:
return "open"
if any(k in name for k in ("框", "梃", "拼")):
return "fixed"
return "both"
def _parse_library(ws) -> list[dict]:
"""解析价格库 sheet → 统一行结构 [{code,name,spec,unit,price,note}]。"""
sn = ws.title
out: list[dict] = []
if sn == "华健开模汇总表":
for r in range(3, ws.max_row + 1):
code = _txt(_col(ws, "B", r))
if not code:
continue
out.append({"code": code, "name": _txt(_col(ws, "C", r)),
"spec": f"{_num(_col(ws, 'D', r)):g}", "unit": "米", "price": 0,
"note": f"模具费{_num(_col(ws, 'E', r)):g}元"})
elif sn == "丝吉利娅五金价格":
for r in range(2, ws.max_row + 1):
code, name = _txt(_col(ws, "B", r)), _txt(_col(ws, "C", r))
if not name:
continue
out.append({"code": code, "name": name, "spec": "", "unit": "套",
"price": round(_num(_col(ws, "D", r)), 2), "note": "丝吉利娅"})
elif sn == "索宾柯价格":
for r in range(2, ws.max_row + 1):
code = _txt(_col(ws, "A", r))
if not code:
continue
out.append({"code": code, "name": f"索宾柯 {code}", "spec": "", "unit": "套",
"price": round(_num(_col(ws, "B", r)), 2), "note": "索宾柯"})
elif sn == "隔热条价格":
for r in range(2, ws.max_row + 1):
code = _txt(_col(ws, "A", r))
if not code:
continue
out.append({"code": code, "name": f"隔热条 {_txt(_col(ws, 'B', r))}",
"spec": f"{_num(_col(ws, 'C', r)):g}g/m", "unit": "米",
"price": round(_num(_col(ws, "E", r)), 4), "note": f"{_num(_col(ws, 'D', r)):g}元/吨"})
elif sn == "胶条价格":
for r in range(3, ws.max_row + 1):
code = _txt(_col(ws, "A", r))
if not code:
continue
out.append({"code": code, "name": f"胶条 {code}",
"spec": f"{_num(_col(ws, 'E', r)):.4f}kg/m", "unit": "米",
"price": round(_num(_col(ws, "G", r)), 4), "note": f"河北三联 {_num(_col(ws, 'F', r)):g}元/箱"})
elif sn == "赛遨价格":
for r in range(3, ws.max_row + 1):
code = _txt(_col(ws, "B", r))
if not code:
continue
note = " | ".join(x for x in (_txt(_col(ws, "E", r)), _txt(_col(ws, "I", r))) if x)
out.append({"code": code, "name": _txt(_col(ws, "C", r)), "spec": "", "unit": "个",
"price": round(_num(_col(ws, "F", r)), 4), "note": note})
elif sn == "螺丝价格":
for r in range(3, ws.max_row + 1):
name = _txt(_col(ws, "B", r))
if not name:
continue
out.append({"code": "", "name": f"{name} {_txt(_col(ws, 'C', r))}",
"spec": _txt(_col(ws, "D", r)), "unit": "个",
"price": round(_num(_col(ws, "G", r)), 4), "note": "山东米兰"})
elif sn == "其他价格":
for r in range(2, ws.max_row + 1):
name = _txt(_col(ws, "A", r))
if not name:
continue
unit_txt, price = _txt(_col(ws, "B", r)), _num(_col(ws, "C", r))
material = _guess_material(name, unit_txt)
unit = {"元/kg": "公斤", "元/m²": "平方米", "元/套": "套", "元/个": "个",
"元/桶": "桶", "元/米": "米"}.get(unit_txt, "个")
out.append({"code": "", "name": name, "spec": "", "unit": unit,
"price": round(price, 4), "note": "", "_material": material})
return out
def _guess_material(name: str, unit_txt: str) -> str:
"""「其他价格」行 → Product.material 分类。"""
if "玻璃" in name or unit_txt == "元/m²":
return "glass"
if any(k in name for k in ("执手", "合页", "隐藏式", "五金")):
return "hardware"
if any(k in name for k in ("型材", "基材")):
return "profile"
return "addon"
def _parse_optimize_stats(ws) -> dict:
"""「铝计算」型材优化单 → 汇总统计(核对参考,不逐行入库)。"""
stats = {"bars": 0, "total_length": 0.0, "sashes": 0, "glasses": 0, "glass_area": 0.0}
for r in range(3, ws.max_row + 1):
code = _txt(_col(ws, "A", r))
if code:
stats["bars"] += 1
stats["total_length"] += _num(_col(ws, "F", r))
if _txt(_col(ws, "H", r)) and _num(_col(ws, "K", r)):
stats["sashes"] += _num(_col(ws, "K", r))
if _txt(_col(ws, "O", r)) and _num(_col(ws, "T", r)):
stats["glasses"] += _num(_col(ws, "T", r))
stats["glass_area"] += _num(_col(ws, "U", r)) * _num(_col(ws, "T", r))
return stats
# ---------------- 入库适配 ----------------
async def import_libraries(db: AsyncSession, company_id: int, libraries: dict) -> dict:
"""价格库 → Product 表。同名同材质已存在则更新价格,否则新建。"""
stats = {"created": 0, "updated": 0}
for sheet, rows in libraries.items():
for row in rows:
material = row.get("_material") or _sheet_material(sheet)
name = row["name"]
existing = (await db.execute(select(Product).where(
Product.company_id == company_id, Product.material == material,
Product.name == name))).scalars().first()
if existing is None:
db.add(Product(company_id=company_id, material=material, name=name,
spec=row.get("spec", ""), unit=row.get("unit", "个"),
price=row.get("price", 0), note=row.get("note", "")))
stats["created"] += 1
else:
if row.get("price"):
existing.price = row["price"]
if row.get("spec"):
existing.spec = row["spec"]
stats["updated"] += 1
await db.flush()
return stats
def _sheet_material(sheet: str) -> str:
if sheet == "华健开模汇总表":
return "profile"
if sheet in ("丝吉利娅五金价格", "索宾柯价格"):
return "hardware"
return "addon"
async def import_series(db: AsyncSession, company_id: int, parsed: dict) -> int:
"""「铝成本」页 → CostSeriesTemplate(同系列同名覆盖)。"""
summary = parsed["series_summary"]
win = summary["window"]
name = f"{win.get('series', 0):g}框扇齐平" if "窗纱" not in parsed["file"] else f"{win.get('series', 0):g}窗纱一体"
existing = (await db.execute(select(CostSeriesTemplate).where(
CostSeriesTemplate.company_id == company_id, CostSeriesTemplate.series == int(win.get("series", 0)),
CostSeriesTemplate.name == name))).scalars().first()
payload = dict(name=name, series=int(win.get("series", 0)),
wall_thick=float(win.get("wall_thick", 1.4)),
source_file=parsed["file"], data=json.dumps(summary, ensure_ascii=False))
if existing is None:
obj = CostSeriesTemplate(company_id=company_id, **payload)
db.add(obj)
await db.flush()
return obj.id
for k, v in payload.items():
setattr(existing, k, v)
await db.flush()
return existing.id
# ---------------- 估算器 ----------------
def estimate(tpl: CostSeriesTemplate, width_mm: float, height_mm: float,
count: int = 1, sash_count: int | None = None) -> dict:
"""按系列模板估算任意尺寸窗型的成本明细(口径对齐核算表)。"""
data = json.loads(tpl.data)
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
sash_count = sash_count if sash_count is not None else max(1, round(count))
base_sash_area = win.get("sash_area") or 0.0001
base_fixed_area = win.get("fixed_area") or (base_area - base_sash_area) or 0.0001
# 新窗型开启面积按基准开启率折算
open_rate = base_sash_area / base_area
new_sash_area = area * open_rate
new_fixed_area = area - new_sash_area
ratio = {"fixed": new_fixed_area / base_fixed_area, "open": new_sash_area / base_sash_area,
"both": area / base_area, "once": float(count)}
def scale_len(row: dict) -> float:
length = row.get("length") or row.get("total") or 0
return length * ratio.get(row.get("alloc", "both"), ratio["both"])
def cost_of(rows, len_key="length") -> tuple[list[dict], float]:
detail = []
total = 0.0
for row in rows:
if row.get("alloc") == "once":
qty = count * (row.get("count") or 1)
amount = qty * row.get("unit_price", 0)
total += amount
detail.append({**row, "qty": qty, "amount": round(amount, 2)})
else:
meters = scale_len(row)
unit_price = row.get("unit_price", 0)
weight = meters * row.get("weight_per_m", 0)
amount = weight * unit_price if row.get("weight_per_m") else meters * unit_price
total += amount
detail.append({**row, "qty": round(meters, 3), "weight": round(weight, 3),
"amount": round(amount, 2)})
return detail, total
out: dict = {"series": tpl.series, "wall_thick": tpl.wall_thick, "name": tpl.name,
"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)}
sections_total = 0.0
detail, cost = cost_of(data.get("profile", []))
out["profile"], sections_total = detail, sections_total + cost
out["profile_cost"] = round(cost, 2)
detail, cost = cost_of(data.get("addon", []))
out["addon"], out["addon_cost"] = detail, round(cost, 2)
sections_total += cost
detail, cost = cost_of(data.get("strip", []))
out["strip"], out["strip_cost"] = detail, round(cost, 2)
sections_total += cost
detail, cost = cost_of(data.get("seal", []))
out["seal"], out["seal_cost"] = detail, round(cost, 2)
sections_total += cost
# 玻璃:模板 area 已是该配置块数总面积,按面积比例缩放
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)
glass_cost += amount
glass_detail.append({**g, "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
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_cost += amount
hw_detail.append({**hw, "qty": qty, "amount": round(amount, 2)})
out["hardware"], out["hardware_cost"] = hw_detail, round(hw_cost, 2)
sections_total += hw_cost
labor = data.get("labor", {})
# 优先用核算表实际每平口径,缺省按参数推导(安装费+对应壁厚人工费)
labor_per_sqm = labor.get("rate_labor") or (
labor.get("install", 0) + (labor.get("w18", 0) if tpl.wall_thick > 1.4 else labor.get("w14", 0)))
other_per_sqm = labor.get("rate_other") or (
labor.get("sales", 0) + labor.get("fuel", 0) + labor.get("ins", 0))
out["labor_cost"] = round(labor_per_sqm * area, 2)
out["other_cost"] = round(other_per_sqm * area, 2)
sections_total += out["labor_cost"] + out["other_cost"]
out["total"] = round(sections_total, 2)
out["per_sqm"] = round(sections_total / area, 2) if area else 0
out["margin_ladder"] = {f"{p}%": round(out["total"] * (1 + p / 100), 2)
for p in (10, 20, 30, 40, 50, 60, 70)}
return out