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:
@@ -1,5 +1,6 @@
|
||||
# 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: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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
@@ -521,3 +521,37 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|
||||
| **领料出库** | POST | `/api/warehouse/pickout` | `{contract_id, note?}` | 对齐 pickout:按领料清单扣库存+流水;库存不足报缺口 |
|
||||
| **采购单** | POST | `/api/purchases` | `{contract_id?(自动读缺料), items?, note?}` | 缺料清单自动生成(CG+日期+流水) |
|
||||
| 采购列表/入库 | 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)。
|
||||
|
||||
@@ -33,6 +33,7 @@ const PAGES = [
|
||||
['printer', '/printer'],
|
||||
['warehouse', '/warehouse'],
|
||||
['products', '/products'],
|
||||
['cost_estimate', '/cost_estimate'],
|
||||
['devices', '/devices'],
|
||||
['market', '/market'],
|
||||
['chart', '/chart'],
|
||||
|
||||
@@ -24,6 +24,7 @@ const router = createRouter({
|
||||
{ path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } },
|
||||
{ path: 'combine', component: () => import('../views/CombineView.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: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
|
||||
{ path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } },
|
||||
|
||||
@@ -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>
|
||||
@@ -34,6 +34,7 @@
|
||||
<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="/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-group>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -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="系列模板已删除")
|
||||
@@ -6,6 +6,7 @@ from huamenchuang.api import (
|
||||
auth,
|
||||
business,
|
||||
contracts,
|
||||
costbook,
|
||||
customers,
|
||||
finance,
|
||||
optimize,
|
||||
@@ -45,6 +46,7 @@ api_router.include_router(salesmen.router)
|
||||
api_router.include_router(purchases.router)
|
||||
api_router.include_router(warehouse.router)
|
||||
api_router.include_router(business.router)
|
||||
api_router.include_router(costbook.router)
|
||||
api_router.include_router(platform.router)
|
||||
api_router.include_router(reports.router)
|
||||
api_router.include_router(platform.router)
|
||||
|
||||
@@ -3,6 +3,7 @@ from huamenchuang.models.announcement import Announcement
|
||||
from huamenchuang.models.business import (
|
||||
AfterSale,
|
||||
ContractReceipt,
|
||||
CostSeriesTemplate,
|
||||
CustomerFollow,
|
||||
MeasureOrder,
|
||||
PriceScript,
|
||||
@@ -42,6 +43,7 @@ __all__ = [
|
||||
"CompanyParam",
|
||||
"Contract",
|
||||
"ContractReceipt",
|
||||
"CostSeriesTemplate",
|
||||
"Customer",
|
||||
"CustomerFollow",
|
||||
"DEFAULT_PROCESSES",
|
||||
|
||||
@@ -127,6 +127,41 @@ class ProductCatelog(Base):
|
||||
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):
|
||||
"""报价单:画布算料结果 + 手动调整,归属客户/合同。"""
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -202,6 +202,33 @@ def main():
|
||||
d = call("POST", f"/api/purchases/{poid}/receive")
|
||||
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 =====")
|
||||
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
|
||||
Reference in New Issue
Block a user