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
@@ -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: '效果图渲染' } },
+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="/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>