feat: v0.3 报价算料引擎与仓库库存模块

后端:
- 报价算料: 从drawing_data几何提取型材清单(外框/中梃/扇框投影长度)
  与玻璃面积(鞋带公式), 套价格本计价, 支持样板/订单/自定义图纸三入口
- 仓库库存: 材料CRUD/出入库/流水/负库存拦截/预警线
- SQLite默认路径锚定项目根目录(修复cwd依赖)
前端:
- 仓库库存页(筛选/出入库对话框/预警高亮)
- 算料报价弹窗(样板库与订单详情入口, 支持打印)
- 路由与导航更新(8页面)
回归: 9个API端点全部200, 报价/优化/SPA路由验证通过
This commit is contained in:
HHY
2026-09-20 08:58:14 -07:00
parent f7a555dd6c
commit 065d387399
16 changed files with 630 additions and 8 deletions
+64
View File
@@ -0,0 +1,64 @@
<template>
<el-dialog v-model="visible" title="算料报价" width="640px">
<div v-if="result" v-loading="loading">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="窗型尺寸">{{ result.width_mm }} × {{ result.height_mm }} mm</el-descriptions-item>
<el-descriptions-item label="玻璃面积">{{ result.glass_area_m2 }} ㎡</el-descriptions-item>
<el-descriptions-item label="总价">
<span class="total">{{ result.total }} 元</span>
</el-descriptions-item>
</el-descriptions>
<el-table :data="result.rows" size="small" border style="margin-top:10px">
<el-table-column prop="note" label="项目" min-width="110">
<template #default="{ row }">{{ row.note || kindLabel(row.kind) }}</template>
</el-table-column>
<el-table-column label="规格" width="110">
<template #default="{ row }">{{ row.length ? row.length + 'mm' : '-' }}</template>
</el-table-column>
<el-table-column prop="qty" label="数量" width="80" />
<el-table-column label="单价" width="90">
<template #default="{ row }">{{ row.unit_price }} {{ row.unit }}</template>
</el-table-column>
<el-table-column prop="amount" label="金额" width="100" />
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button type="primary" @click="print">打印报价单</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { api } from '../api'
const visible = ref(false)
const loading = ref(false)
const result = ref<any>(null)
let source: { drawing_data?: string; template_id?: number; order_id?: number } = {}
function kindLabel(k: string) {
return ({ frame: '外框', mullion: '中梃', sash: '扇框', glass: '玻璃', hardware: '五金' } as Record<string, string>)[k] ?? k
}
async function open(src: typeof source) {
source = src
visible.value = true
loading.value = true
try {
result.value = await api<any>('post', '/api/offer-price', src)
} finally { loading.value = false }
}
function print() {
window.print()
}
defineExpose({ open })
</script>
<style scoped>
.total { color: #e6483d; font-size: 18px; font-weight: 700; }
@media print { .el-dialog__footer { display: none; } }
</style>
+1
View File
@@ -16,6 +16,7 @@ const router = createRouter({
{ path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } },
{ path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } },
{ path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } },
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
],
},
+1
View File
@@ -9,6 +9,7 @@
<el-menu-item index="/contracts"><el-icon><Document /></el-icon>合同管理</el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon>客户管理</el-menu-item>
<el-menu-item index="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item>
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
</el-menu>
</el-aside>
+7
View File
@@ -72,6 +72,8 @@
<span>客户:{{ detail.customer_name || '-' }}</span>
<span>金额:{{ detail.money }}</span>
<span>面积:{{ detail.area }}㎡</span>
<el-button size="small" type="success" plain @click="quoteRef.open({ order_id: detail.id })">算料报价</el-button>
<el-button size="small" @click="printOrder">打印订单</el-button>
</div>
<div class="canvas-box">
<WindoorCanvas :drawing-data="detail.drawing_data" />
@@ -92,6 +94,7 @@
</div>
</div>
</el-drawer>
<QuoteDialog ref="quoteRef" />
</div>
</template>
@@ -101,8 +104,12 @@ import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue'
import QuoteDialog from '../components/QuoteDialog.vue'
const route = useRoute()
const quoteRef = ref()
function printOrder() { window.print() }
const statusLabels: Record<number, string> = { 0: '意向单', 1: '正式单', 2: '生产中', 3: '已完工', 4: '已取消' }
+4
View File
@@ -28,10 +28,12 @@
<div class="meta">
<span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</span>
<span>引用次数: {{ current.times }}</span>
<el-button type="success" size="small" @click="quoteRef.open({ template_id: current.id })">算料报价</el-button>
<el-button type="primary" size="small" @click="useInOrder">用此样板下单</el-button>
</div>
</div>
</el-dialog>
<QuoteDialog ref="quoteRef" />
</div>
</template>
@@ -40,8 +42,10 @@ import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue'
import QuoteDialog from '../components/QuoteDialog.vue'
const router = useRouter()
const quoteRef = ref()
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
+120
View File
@@ -0,0 +1,120 @@
<template>
<el-card>
<div class="toolbar">
<el-input v-model="keyword" placeholder="名称/编码" clearable style="width:200px" @keyup.enter="load" />
<el-select v-model="material" placeholder="全部类型" clearable style="width:130px" @change="load">
<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="load">查询</el-button>
<div style="flex:1" />
<el-button type="primary" plain @click="openEdit()">新增材料</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="bom_code" label="编码" width="90" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="类型" width="80">
<template #default="{ row }">{{ materialLabel(row.material) }}</template>
</el-table-column>
<el-table-column prop="spec" label="规格" width="110" />
<el-table-column prop="color" label="颜色" width="90" />
<el-table-column label="库存" width="110">
<template #default="{ row }">
<span :class="{ low: row.warning > 0 && row.count <= row.warning }">{{ row.count }}</span>
{{ row.unit }}
</template>
</el-table-column>
<el-table-column prop="price" label="单价" width="90" />
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" plain @click="move(row, 1)">入</el-button>
<el-button size="small" type="warning" plain @click="move(row, -1)">出</el-button>
<el-button size="small" @click="openEdit(row)">编</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
<el-dialog v-model="editVisible" :title="form.id ? '编辑材料' : '新增材料'" width="520px">
<el-form :model="form" label-width="80px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="类型">
<el-select v-model="form.material" style="width:100%">
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" />
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-select>
</el-form-item>
<el-form-item label="规格"><el-input v-model="form.spec" /></el-form-item>
<el-form-item label="颜色"><el-input v-model="form.color" /></el-form-item>
<el-form-item label="单位"><el-input v-model="form.unit" style="width:100px" /></el-form-item>
<el-form-item label="单价"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
<el-form-item label="库存"><el-input-number v-model="form.count" :min="0" /></el-form-item>
<el-form-item label="预警线"><el-input-number v-model="form.warning" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 15
const keyword = ref('')
const material = ref<string | undefined>()
const loading = ref(false)
const editVisible = ref(false)
const form = reactive<any>({ id: 0, name: '', material: 'profile', spec: '', color: '', unit: '根', price: 0, count: 0, warning: 0 })
function materialLabel(m: string) {
return ({ profile: '型材', glass: '玻璃', hardware: '五金', addon: '配件' } as Record<string, string>)[m] ?? m
}
async function load() {
loading.value = true
try {
const qs = `page=${page.value}&page_size=${page_size}&keyword=${keyword.value}${material.value ? `&material=${material.value}` : ''}`
const d = await api<any>('get', `/api/warehouse/list?${qs}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
function openEdit(row?: any) {
Object.assign(form, row ?? { id: 0, name: '', material: 'profile', spec: '', color: '', unit: '根', price: 0, count: 0, warning: 0 })
editVisible.value = true
}
async function save() {
if (form.id) await api('put', `/api/warehouse/${form.id}`, form)
else await api('post', '/api/warehouse', form)
ElMessage.success('已保存')
editVisible.value = false
load()
}
async function move(row: any, direction: number) {
const { value } = await ElMessageBox.prompt(
`${row.name} ${direction === 1 ? '入库' : '出库'}数量(${row.unit})`, '出入库',
{ inputValue: '1', inputPattern: /^\d+(\.\d+)?$/, inputErrorMessage: '请输入数字' })
await api('post', '/api/warehouse/move', { item_id: row.id, direction, count: Number(value), note: '页面操作' })
ElMessage.success('操作成功')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.low { color: #f56c6c; font-weight: 700; }
</style>