feat: P1批次3 生产域(车间管理/扫码跟踪/双通道优化/订单利润/条码打印)

This commit is contained in:
HHY
2026-09-25 06:39:07 -07:00
parent d05a91b7bc
commit fbe21c17b5
16 changed files with 683 additions and 7 deletions
+2
View File
@@ -1,5 +1,7 @@
# CHANGELOG
- [2026-09-25 21:50] FEAT: v2.31 P1 批次3 生产域 4 项。①车间管理: Staff/ProcessAssignment 模型+/api/workshop(staff CRUD/assign 派工/assignments 派工历史/scan 扫码跟踪/board 跟单中心看板), 新页 /workshop 三 Tab(看板按环节列/环节扫码 BarcodeDetector 摄像头+扫码枪输入框/员工名册+派工), 路由菜单; ②双通道优化: optimize_1d_fine(BFD 最佳适应递减+有界余料均衡, 修复 while 死循环卡死事件循环)+optimize1d method=fast|fine, 优化页通道选择; ③订单利润: GET /reports/order-profit(订单金额 vs BOM 材料成本估算, 价格本成本均价口径)+利润汇总卡+明细表, 报表页嵌入; ④条码打印: 打印模板加 barcode 区块(JsBarcode Code128 订单号)+模板管理可加条码块; vue-tsc 0 错+构建通过; API 实测双通道/看板/利润/员工 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/workshop.py, src/huamenchuang/api/optimize.py, src/huamenchuang/services/optimize_service.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/router.py, frontend/src/views/WorkshopView.vue, frontend/src/views/OptimizeView.vue, frontend/src/views/ReportView.vue, frontend/src/views/PrinterView.vue, frontend/src/router/index.ts, frontend/src/views/LayoutView.vue)
- [2026-09-25 21:00] FEAT: v2.30 P1 批次2 报价/产品域 3 项。①型材系列管理: /api/product-catelogs 树 CRUD(系列下有产品禁删)+产品挂系列(catelog_id)+按系列筛选, 产品页左树右表布局+编辑表单系列选择+补回丢失的产品编辑对话框; ②副梃计价收尾: 产品类别加 subbar(按尺寸/米计价, 随逐格编辑 assigns.subBar 算料); ③成本报价: 报价保存/更新按产品名匹配价格本成本写行 _cost(quote_by_cost 语义)+响应加 cost_total/profit, 报价页加成本/毛利列+分享链接(quote_link 走 shares type=quote); 实测: 成本匹配 6×38=228 毛利 84; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/api/business.py, frontend/src/views/ProductsView.vue, frontend/src/views/QuotationsView.vue, doc/API.md)
- [2026-09-25 20:10] FEAT: v2.29 P1 批次1 设计域 5 项。①AI 推荐窗型: api/recommend.py 规则引擎(洞口宽高比/玻璃面积比/开扇数/门类/纱扇打分)+需求标签规则(通风/采光/隔音/安全/景观/通行)+可解释理由 TOP5, 样板库页"AI 推荐"Tab; ②通用分享体系: ShareItem 模型+POST/GET /api/shares(render/combine/quote/sunny 四类)+公开页 /share/s/:token(大图/报价明细/组合只读画布), 组合页/AI 炫图/报价单三处分享按钮; ③阳光房设计: SunroomDesign 模型+CRUD+SunroomCanvas 参数化绘图(flat 平顶/gable 人字顶 正视+俯视+尺寸标注)+AI 效果图(scene=sunroom-*, 提示词含 bays/footprint)+设计分享; ④量尺拍照标注: 照片上传+Canvas 拖拽画尺寸线(颜色/标签/撤销)+合成图存洞口行 photo; ⑤扇/玻璃逐格指定: SashAssign(decs 同型自研扩展)+画布"扇/玻璃"对话框(五类开启类型/内外开/铰链侧/副梃/玻璃配置)+2D 开向三角渲染+takeoff 计指定扇框料五金副梃(subbar 25 元/米); vue-tsc 0 错+构建通过, 推荐/阳光房/分享 API 实测通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/shares.py, src/huamenchuang/api/recommend.py, src/huamenchuang/api/sunroom.py, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/services/quote_service.py, frontend/src/views/ShareView.vue, frontend/src/views/SunnyHouseView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/MeasureView.vue, frontend/src/views/CombineView.vue, frontend/src/views/AIRenderView.vue, frontend/src/views/QuotationsView.vue, frontend/src/components/SunroomCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/router/index.ts, doc/API.md)
+35
View File
@@ -12,12 +12,14 @@
"@types/three": "^0.186.0",
"axios": "^1.7.0",
"element-plus": "^2.9.0",
"jsbarcode": "^3.12.3",
"pinia": "^2.2.0",
"three": "^0.186.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/jsbarcode": "^3.11.4",
"@vitejs/plugin-vue": "^5.1.0",
"puppeteer-core": "^25.11.0",
"typescript": "^5.5.0",
@@ -979,6 +981,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsbarcode": {
"version": "3.11.4",
"resolved": "https://registry.npmmirror.com/@types/jsbarcode/-/jsbarcode-3.11.4.tgz",
"integrity": "sha512-VBcpTAnEMH0Gbh8JpV14CgOtJjCYjsvR2FoDRyoYPE0gUxtApf8N4c+HKEOyz/iiIZkMzqrzBA3XX7+KgKxxsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.25",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
@@ -994,6 +1006,16 @@
"@types/lodash": "*"
}
},
"node_modules/@types/node": {
"version": "26.6.2",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.2.tgz",
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
@@ -1774,6 +1796,12 @@
"node": ">= 6"
}
},
"node_modules/jsbarcode": {
"version": "3.12.3",
"resolved": "https://registry.npmmirror.com/jsbarcode/-/jsbarcode-3.12.3.tgz",
"integrity": "sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@@ -2120,6 +2148,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+2
View File
@@ -13,12 +13,14 @@
"@types/three": "^0.186.0",
"axios": "^1.7.0",
"element-plus": "^2.9.0",
"jsbarcode": "^3.12.3",
"pinia": "^2.2.0",
"three": "^0.186.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/jsbarcode": "^3.11.4",
"@vitejs/plugin-vue": "^5.1.0",
"puppeteer-core": "^25.11.0",
"typescript": "^5.5.0",
+1
View File
@@ -30,6 +30,7 @@ const router = createRouter({
{ path: 'ai_render', component: () => import('../views/AIRenderView.vue'), meta: { title: 'AI 炫图' } },
{ path: 'threed', component: () => import('../views/RenderView.vue'), meta: { title: '3D 展示' } },
{ path: 'printer', component: () => import('../views/PrinterView.vue'), meta: { title: '打印中心' } },
{ path: 'workshop', component: () => import('../views/WorkshopView.vue'), meta: { title: '车间管理' } },
{ path: 'cut_solution', component: () => import('../views/CutSolutionView.vue'), meta: { title: '下料方案' } },
{ path: 'devices', component: () => import('../views/DevicesView.vue'), meta: { title: '设备管理' } },
{ path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } },
+1
View File
@@ -30,6 +30,7 @@
<el-menu-item index="/measure"><el-icon><ScaleToOriginal /></el-icon>量尺管理</el-menu-item>
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
<el-menu-item index="/cut_solution"><el-icon><SetUp /></el-icon>下料方案</el-menu-item>
<el-menu-item index="/workshop"><el-icon><SetUp /></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="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
+13 -1
View File
@@ -1,4 +1,13 @@
<template>
<el-card>
<div class="row" style="margin-bottom:10px">
<span>优化通道:</span>
<el-radio-group v-model="method" size="small">
<el-radio-button value="fast">快速优化(FFD)</el-radio-button>
<el-radio-button value="fine">详细优化(BFD+余料均衡)</el-radio-button>
</el-radio-group>
<span class="tip">详细优化逐件选择最贴合原料并做余料均衡,综合利用率更高(对齐源站双通道)</span>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-card header="型材需求(每件长度 × 数量)">
@@ -59,6 +68,7 @@
</el-table>
</template>
</el-card>
</el-card>
</template>
<script setup lang="ts">
@@ -69,13 +79,14 @@ import { api } from '../api'
const demands = reactive([{ label: '边框', length: 1200, qty: 3 }, { label: '中梃', length: 800, qty: 2 }])
const stocks = reactive([{ label: '6米型材', length: 6000, qty: 2 }])
const kerf = ref(5)
const method = ref<'fast' | 'fine'>('fast')
const loading = ref(false)
const result = ref<any>(null)
async function run() {
loading.value = true
try {
result.value = await api<any>('post', '/api/optimize1d', { demands, stocks, kerf: kerf.value })
result.value = await api<any>('post', '/api/optimize1d', { demands, stocks, kerf: kerf.value, method: method.value })
ElMessage.success(`优化完成,利用率 ${(result.value.stats.utilization * 100).toFixed(1)}%`)
} finally { loading.value = false }
}
@@ -83,6 +94,7 @@ async function run() {
<style scoped>
.row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
.tip { color: #909399; font-size: 12px; }
.result-head { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
.util { font-size: 20px; font-weight: 700; }
.util-label { color: #909399; }
+25 -2
View File
@@ -49,6 +49,9 @@
</table>
</template>
<p v-else-if="b.type === 'sign'" class="sign">客户签字:____________  日期:____________</p>
<div v-else-if="b.type === 'barcode'" class="barcode-box">
<svg v-for="(o, i) in [current]" :key="i" :ref="el => setBarcodeRef(el as SVGElement)" class="barcode-svg" />
</div>
</template>
</div>
<el-empty v-else description="选择订单后预览打印" />
@@ -114,7 +117,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
@@ -129,7 +132,7 @@ const FIELD_DEFS: Record<string, { label: string; render: (o: any) => string }>
created_at: { label: '下单时间', render: o => String(o.created_at ?? '').slice(0, 10) },
note: { label: '备注', render: o => o.note || '-' },
}
const BLOCK_LABEL: Record<string, string> = { title: '标题', info: '订单信息', image: '图纸', table: '表格', sign: '签字' }
const BLOCK_LABEL: Record<string, string> = { title: '标题', info: '订单信息', image: '图纸', table: '表格', sign: '签字', barcode: '条码' }
const orders = ref<any[]>([])
const orderId = ref<number | undefined>()
@@ -179,6 +182,10 @@ async function refreshBom() {
function onFormatChange() { refreshBom() }
// 模板含条码块时渲染条码
watch([() => current.value?.id, () => formatId.value], () => renderBarcodes())
watch(() => fmtVisible.value, v => { if (!v) renderBarcodes() })
function print() { window.print() }
// ---- 模板管理 ----
@@ -197,10 +204,26 @@ function addBlock(t: string) {
image: { type: 'image' },
table: { type: 'table', kind: 'process' },
sign: { type: 'sign' },
barcode: { type: 'barcode' },
}
fmtForm.blocks.push(JSON.parse(JSON.stringify(seed[t])))
}
// ---- 条码渲染(Code128, 订单号) ----
const barcodeRefs: SVGElement[] = []
function setBarcodeRef(el: SVGElement | null) {
if (el && !barcodeRefs.includes(el)) barcodeRefs.push(el)
}
async function renderBarcodes() {
if (!current.value) return
await nextTick()
const JsBarcode = (await import('jsbarcode')).default
for (const svg of barcodeRefs) {
try { JsBarcode(svg, current.value.order_no || '0000', { format: 'CODE128', height: 44, fontSize: 13, margin: 4 }) } catch { /* 忽略 */ }
}
}
function move(idx: number | string, dir: number) {
const i = Number(idx)
const arr = fmtForm.blocks
+33
View File
@@ -39,6 +39,31 @@
<el-tag v-for="s in statusDist" :key="s.status" style="margin:2px">
{{ statusLabel(s.status) }}:{{ s.count }}</el-tag>
</el-space>
<!-- 订单利润(order-profit 语义:金额 vs 材料成本估算) -->
<div class="no-print">
<h4 class="sec">订单利润
<el-button size="small" text type="primary" @click="loadProfit">刷新近 90 天</el-button>
</h4>
<el-row :gutter="12" v-if="profit.summary">
<el-col :span="6"><div class="stat"><span class="num">¥{{ profit.summary.total_money }}</span><span class="label">订单总额</span></div></el-col>
<el-col :span="6"><div class="stat"><span class="num">¥{{ profit.summary.total_cost }}</span><span class="label">材料成本估算</span></div></el-col>
<el-col :span="6"><div class="stat"><span class="num" :style="{ color: profit.summary.total_profit >= 0 ? '#67c23a' : '#e6483d' }">¥{{ profit.summary.total_profit }}</span><span class="label">毛利</span></div></el-col>
<el-col :span="6"><div class="stat"><span class="num">{{ profit.summary.avg_margin }}%</span><span class="label">平均毛利率</span></div></el-col>
</el-row>
<el-table :data="profit.rows || []" size="small" border max-height="320" style="margin-top:10px">
<el-table-column prop="order_no" label="订单号" width="160" />
<el-table-column prop="customer_name" label="客户" min-width="100" />
<el-table-column prop="money" label="金额" width="100" />
<el-table-column prop="cost" label="成本估算" width="100" />
<el-table-column label="毛利" width="100">
<template #default="{ row }">
<span :style="{ color: row.profit >= 0 ? '#67c23a' : '#e6483d' }">¥{{ row.profit }}</span>
</template>
</el-table-column>
<el-table-column prop="margin" label="毛利率%" width="90" />
</el-table>
</div>
</el-card>
</template>
@@ -78,6 +103,14 @@ async function load() {
function exportExcel() { apiDownload('/api/reports/excel-statistics', 'statistics.xlsx') }
// ---- 订单利润 ----
const profit = ref<any>({})
async function loadProfit() {
profit.value = await api<any>('get', '/api/reports/order-profit?days=90')
}
onMounted(loadProfit)
onMounted(load)
</script>
+248
View File
@@ -0,0 +1,248 @@
<template>
<div>
<el-tabs v-model="tab">
<!-- ============ 跟单中心看板 ============ -->
<el-tab-pane label="跟单中心" name="board">
<div class="board" v-loading="boardLoading">
<div v-for="(list, name) in board.columns || {}" :key="name" class="board-col">
<div class="col-head">{{ name }}<el-badge :value="list.length" type="info" class="col-bdg" /></div>
<div v-for="o in list" :key="o.id" class="col-card">
<b>{{ o.order_no }}</b>
<span>{{ o.customer_name || '-' }}</span>
<span class="money">¥{{ o.money }}</span>
</div>
<div v-if="!list.length" class="col-empty">-</div>
</div>
</div>
</el-tab-pane>
<!-- ============ 环节扫码 ============ -->
<el-tab-pane label="环节扫码" name="scan">
<div class="scan-bar">
<el-input v-model="scanNo" placeholder="输入或扫描订单号条码" style="width:280px"
@keyup.enter="doScan" clearable>
<template #append><el-button @click="startScan">摄像头</el-button></template>
</el-input>
<el-button type="primary" @click="doScan">查询进度</el-button>
<video ref="videoRef" class="scan-video" v-show="scanning" />
</div>
<el-alert v-if="scanError" :title="scanError" type="error" :closable="false" show-icon style="margin-bottom:10px" />
<el-card v-if="scanResult" shadow="never">
<template #header>
<div class="scan-head">
<span>{{ scanResult.order_no }} · {{ scanResult.customer_name || '-' }}({{ scanResult.status_label }})</span>
<span class="money">¥{{ scanResult.money }}</span>
</div>
</template>
<el-steps :active="scanActive" align-center>
<el-step v-for="p in scanResult.processes" :key="p.name" :title="p.name"
:status="p.status === 2 ? 'success' : p.status === 1 ? 'process' : 'wait'" />
</el-steps>
<div class="scan-ops">
<el-select v-model="scanProcess" style="width:120px" size="small">
<el-option v-for="p in scanResult.processes" :key="p.name" :label="p.name" :value="p.name" />
</el-select>
<el-select v-model="scanStatus" style="width:100px" size="small">
<el-option :value="0" label="未开始" /><el-option :value="1" label="进行中" /><el-option :value="2" label="完成" />
</el-select>
<el-button type="primary" size="small" @click="applyScanStatus">更新环节</el-button>
<span class="tip">扫码枪可直接对订单条码扫入输入框</span>
</div>
</el-card>
</el-tab-pane>
<!-- ============ 员工与派工 ============ -->
<el-tab-pane label="员工与派工" name="staff">
<el-row :gutter="14">
<el-col :span="10">
<el-card shadow="never" header="车间员工">
<el-table :data="staff" size="small" border>
<el-table-column prop="name" label="姓名" min-width="80" />
<el-table-column prop="role" label="环节" width="80" />
<el-table-column prop="phone" label="电话" width="110" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button size="small" text type="danger" @click="removeStaff(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<div class="staff-add">
<el-input v-model="staffForm.name" size="small" placeholder="姓名" style="width:90px" />
<el-select v-model="staffForm.role" size="small" style="width:90px">
<el-option v-for="r in PROCESSES" :key="r" :label="r" :value="r" />
</el-select>
<el-input v-model="staffForm.phone" size="small" placeholder="电话" style="width:110px" />
<el-button size="small" type="primary" plain @click="addStaff">添加</el-button>
</div>
</el-card>
</el-col>
<el-col :span="14">
<el-card shadow="never" header="派工(进度节点指派员工)">
<div class="assign-form">
<el-select v-model="assignForm.order_id" filterable placeholder="选择订单" style="width:230px" size="small">
<el-option v-for="o in orderOptions" :key="o.id" :label="`${o.order_no} ${o.customer_name || ''}`" :value="o.id" />
</el-select>
<el-select v-model="assignForm.process_name" placeholder="环节" style="width:100px" size="small">
<el-option v-for="r in PROCESSES" :key="r" :label="r" :value="r" />
</el-select>
<el-select v-model="assignForm.staff_id" placeholder="员工" style="width:120px" size="small">
<el-option v-for="s in staff" :key="s.id" :label="`${s.name}(${s.role})`" :value="s.id" />
</el-select>
<el-button type="primary" size="small" @click="assign">派工</el-button>
</div>
<el-table :data="assignments" size="small" border max-height="360">
<el-table-column prop="created_at" label="时间" width="150" />
<el-table-column prop="order_no" label="订单" width="150" />
<el-table-column prop="process_name" label="环节" width="80" />
<el-table-column prop="staff_name" label="员工" width="90" />
<el-table-column prop="note" label="备注" min-width="90" />
</el-table>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const PROCESSES = ['下料', '组装', '玻璃', '打胶', '五金', '质检', '完工']
const tab = ref('board')
const board = ref<any>({})
const boardLoading = ref(false)
const staff = ref<any[]>([])
const staffForm = reactive<any>({ name: '', role: '下料', phone: '' })
const orderOptions = ref<any[]>([])
const assignForm = reactive<any>({ order_id: undefined, process_name: '下料', staff_id: undefined })
const assignments = ref<any[]>([])
// ---- 看板 ----
async function loadBoard() {
boardLoading.value = true
try { board.value = await api<any>('get', '/api/workshop/board') } finally { boardLoading.value = false }
}
// ---- 扫码 ----
const scanNo = ref('')
const scanResult = ref<any>(null)
const scanError = ref('')
const scanProcess = ref('下料')
const scanStatus = ref(2)
const videoRef = ref<HTMLVideoElement>()
const scanning = ref(false)
let scanStream: MediaStream | null = null
let detector: any = null
const scanActive = computed(() =>
(scanResult.value?.processes ?? []).filter((p: any) => p.status === 2).length)
async function doScan() {
scanError.value = ''
scanResult.value = null
if (!scanNo.value.trim()) return
try {
scanResult.value = await api<any>('get', `/api/workshop/scan/${encodeURIComponent(scanNo.value.trim())}`)
scanProcess.value = scanResult.value.processes.find((p: any) => p.status !== 2)?.name ?? '完工'
} catch { scanError.value = `未找到订单 ${scanNo.value}` }
}
async function startScan() {
if (scanning.value) { stopScan(); return }
try {
scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
const video = videoRef.value!
video.srcObject = scanStream
await video.play()
scanning.value = true
const Detector = (window as any).BarcodeDetector
if (!Detector) return ElMessage.warning('当前浏览器不支持条码识别,请用扫码枪或手动输入')
detector = new Detector({ formats: ['code_128', 'code_39', 'qr_code'] })
const tick = async () => {
if (!scanning.value || !detector || !videoRef.value) return
try {
const codes = await detector.detect(videoRef.value)
if (codes.length) {
scanNo.value = codes[0].rawValue
stopScan()
doScan()
return
}
} catch { /* 帧失败忽略 */ }
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
} catch { ElMessage.error('无法访问摄像头') }
}
function stopScan() {
scanning.value = false
scanStream?.getTracks().forEach(t => t.stop())
scanStream = null
}
async function applyScanStatus() {
if (!scanResult.value) return
await api('post', `/api/orders/${scanResult.value.id}/process`, {
name: scanProcess.value, status: scanStatus.value,
})
ElMessage.success(`${scanProcess.value} 环节已更新`)
doScan()
}
// ---- 员工与派工 ----
async function loadStaff() {
staff.value = (await api<any[]>('get', '/api/workshop/staff')) ?? []
}
async function addStaff() {
if (!staffForm.name.trim()) return ElMessage.warning('请填写姓名')
await api('post', '/api/workshop/staff', staffForm)
ElMessage.success('已添加')
staffForm.name = ''; staffForm.phone = ''
loadStaff()
}
async function removeStaff(row: any) {
await ElMessageBox.confirm(`确定删除员工「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/workshop/staff/${row.id}`)
loadStaff()
}
async function assign() {
if (!assignForm.order_id || !assignForm.staff_id) return ElMessage.warning('请选择订单与员工')
await api('post', '/api/workshop/assign', assignForm)
ElMessage.success('已派工')
loadAssignments()
}
async function loadAssignments() {
assignments.value = ((await api<any>('get', '/api/workshop/assignments?page=1&page_size=30')).data) ?? []
}
onMounted(async () => {
loadBoard(); loadStaff(); loadAssignments()
orderOptions.value = ((await api<any>('post', '/api/orders/search', { page: 1, page_size: 100 })).data) ?? []
})
onBeforeUnmount(stopScan)
</script>
<style scoped>
.board { display: flex; gap: 10px; overflow-x: auto; min-height: 300px; }
.board-col { flex: 1; min-width: 130px; background: #f7f8fa; border-radius: 8px; padding: 8px; }
.col-head { font-weight: 700; color: #303133; font-size: 13px; padding: 2px 4px 8px; display: flex; justify-content: space-between; }
.col-card { background: #fff; border: 1px solid #e4e7ed; border-radius: 6px; padding: 6px 8px; margin-bottom: 8px;
display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #606266; }
.col-empty { text-align: center; color: #c0c4cc; }
.money { color: #e6483d; }
.scan-bar { display: flex; gap: 10px; margin-bottom: 12px; align-items: flex-start; }
.scan-video { width: 260px; height: 160px; border-radius: 8px; background: #000; object-fit: cover; }
.scan-head { display: flex; justify-content: space-between; font-weight: 600; }
.scan-ops { margin-top: 14px; display: flex; gap: 10px; align-items: center; }
.tip { color: #909399; font-size: 12px; }
.staff-add { display: flex; gap: 6px; margin-top: 10px; }
.assign-form { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
</style>
+7 -3
View File
@@ -4,7 +4,7 @@ from pydantic import BaseModel, Field
from huamenchuang.core.deps import CurrentUser
from huamenchuang.core.response import fail, ok
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d, optimize_1d_fine
router = APIRouter(prefix="/optimize1d", tags=["优化下料"])
@@ -13,16 +13,20 @@ class OptimizeRequest(BaseModel):
demands: list[BarDemand] = Field(description="型材需求 [{length,qty,label}]")
stocks: list[StockBar] = Field(description="库存原料 [{length,qty,label}]")
kerf: int = Field(default=5, ge=0, le=50, description="锯口损耗(mm)")
method: str = Field(default="fast", pattern="^(fast|fine)$",
description="fast=快速优化(FFD) fine=详细优化(BFD+余料均衡)")
@router.post("")
async def optimize(body: OptimizeRequest, user: CurrentUser):
"""一维优化下料(FFD 首递减算法)。"""
"""一维优化下料(双通道:快速 FFD / 详细 BFD,对齐源站双通道语义)。"""
if not body.demands or not body.stocks:
return fail("需求和库存均不能为空")
total_demand = sum(d.length * d.qty for d in body.demands)
total_stock = sum(s.length * s.qty for s in body.stocks)
if total_demand > total_stock:
return fail(f"库存不足: 需求 {total_demand}mm > 库存 {total_stock}mm")
result = optimize_1d(body.demands, body.stocks, body.kerf)
result = (optimize_1d_fine if body.method == "fine" else optimize_1d)(
body.demands, body.stocks, body.kerf)
result["method"] = body.method
return ok(data=result, message="优化完成")
+48
View File
@@ -100,3 +100,51 @@ async def excel_statistics(user: CurrentUser, db: DbSession):
content = statistics_workbook(summary, receipts)
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=statistics.xlsx"})
@router.get("/order-profit")
async def order_profit(user: CurrentUser, db: DbSession, days: int = 90):
"""订单利润报表(对齐目标站 order-profit):订单金额 vs 材料成本估算(价格本成本口径)。"""
from huamenchuang.core.drawing import parse_drawing
from huamenchuang.services.quote_service import takeoff
from huamenchuang.models.business import Product
cid = user.company_id
since = datetime.utcnow() - timedelta(days=days)
orders = list((await db.execute(
select(Order).where(Order.company_id == cid, Order.created_at >= since)
.order_by(Order.id.desc()).limit(100))).scalars())
# 成本均价(价格本成本)
async def avg_cost(material: str) -> float:
v = (await db.execute(select(func.coalesce(func.avg(Product.cost), 0)).where(
Product.company_id == cid, Product.material == material, Product.enabled == 1))).scalar()
return float(v or 0)
cost_profile = await avg_cost("profile")
cost_glass = await avg_cost("glass")
cost_hw = await avg_cost("hardware")
rows, total_money, total_cost = [], 0.0, 0.0
for o in orders:
cost = 0.0
if o.drawing_data:
try:
t = takeoff(parse_drawing(o.drawing_data))
meters = sum(b.length * b.qty for b in t.bars) / 1000
cost = meters * cost_profile + t.glass_area_m2 * cost_glass + t.sash_count * cost_hw
except Exception:
cost = 0.0
money = float(o.money or 0)
total_money += money
total_cost += cost
rows.append({"id": o.id, "order_no": o.order_no, "customer_name": o.customer_name,
"money": money, "cost": round(cost, 2), "profit": round(money - cost, 2),
"margin": round((money - cost) / money * 100, 1) if money else 0,
"created_at": str(o.created_at)[:10]})
return ok(data={"rows": rows, "summary": {
"order_count": len(rows),
"total_money": round(total_money, 2),
"total_cost": round(total_cost, 2),
"total_profit": round(total_money - total_cost, 2),
"avg_margin": round((total_money - total_cost) / total_money * 100, 1) if total_money else 0,
"cost_profile": cost_profile, "cost_glass": cost_glass, "cost_hardware": cost_hw,
}})
+2
View File
@@ -20,6 +20,7 @@ from huamenchuang.api import (
sunroom,
warehouse,
windoor,
workshop,
)
api_router = APIRouter(prefix="/api")
@@ -37,6 +38,7 @@ api_router.include_router(ai_render.router)
api_router.include_router(shares.router)
api_router.include_router(recommend.router)
api_router.include_router(sunroom.router)
api_router.include_router(workshop.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
+141
View File
@@ -0,0 +1,141 @@
"""车间管理路由:员工名册、派工、扫码跟踪、车间看板(对齐目标站 manage/workshop)。"""
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.extend import ProcessAssignment, Staff
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderProcess
router = APIRouter(prefix="/workshop", tags=["车间管理"])
# ---------------- 员工名册 ----------------
class StaffBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
phone: str = ""
role: str = Field(default="下料")
status: int = Field(default=1, ge=0, le=1)
@router.get("/staff")
async def list_staff(user: CurrentUser, db: DbSession):
rows = list((await db.execute(select(Staff).where(
Staff.company_id == user.company_id).order_by(Staff.id))).scalars())
return ok(data=[{"id": s.id, "name": s.name, "phone": s.phone, "role": s.role,
"status": s.status} for s in rows])
@router.post("/staff")
async def create_staff(body: StaffBody, user: CurrentUser, db: DbSession):
s = Staff(company_id=user.company_id, **body.model_dump())
db.add(s)
await db.flush()
return ok(data={"id": s.id}, message="员工已添加")
@router.put("/staff/{sid}")
async def update_staff(sid: int, body: StaffBody, user: CurrentUser, db: DbSession):
s = await db.get(Staff, sid)
if s is None or s.company_id != user.company_id:
return fail("员工不存在")
for k, v in body.model_dump().items():
setattr(s, k, v)
return ok(message="已更新")
@router.delete("/staff/{sid}")
async def delete_staff(sid: int, user: CurrentUser, db: DbSession):
s = await db.get(Staff, sid)
if s is None or s.company_id != user.company_id:
return fail("员工不存在")
await db.delete(s)
return ok(message="已删除")
# ---------------- 派工 ----------------
class AssignBody(BaseModel):
order_id: int
process_name: str = Field(min_length=1, max_length=16)
staff_id: int
note: str = ""
@router.post("/assign")
async def assign(body: AssignBody, user: CurrentUser, db: DbSession):
"""派工:进度节点指派员工(记录 staffHistory)。"""
order = await db.get(Order, body.order_id)
if order is None or order.company_id != user.company_id:
return fail("订单不存在")
staff = await db.get(Staff, body.staff_id)
if staff is None or staff.company_id != user.company_id:
return fail("员工不存在")
rec = ProcessAssignment(
company_id=user.company_id, order_id=order.id, order_no=order.order_no,
process_name=body.process_name, staff_id=staff.id, staff_name=staff.name,
note=body.note, user_id=user.id)
db.add(rec)
await db.flush()
return ok(data={"id": rec.id}, message=f"已派工:{body.process_name} → {staff.name}")
@router.get("/assignments")
async def list_assignments(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 30):
"""派工历史(staffHistory 语义)。"""
where = (ProcessAssignment.company_id == user.company_id,)
total = (await db.execute(select(func.count()).select_from(ProcessAssignment).where(*where))).scalar_one()
rows = (await db.execute(select(ProcessAssignment).where(*where)
.order_by(ProcessAssignment.id.desc())
.offset((page - 1) * page_size).limit(page_size))).scalars()
return ok(data={"current_page": page, "per_page": page_size, "total": total,
"data": [{"id": r.id, "order_no": r.order_no, "process_name": r.process_name,
"staff_name": r.staff_name, "note": r.note,
"created_at": str(r.created_at)[:19]} for r in rows]})
# ---------------- 扫码跟踪 ----------------
@router.get("/scan/{order_no}")
async def scan_order(order_no: str, user: CurrentUser, db: DbSession):
"""扫码跟踪(scan_tracking):按订单号取订单与进度(对齐环节扫码)。"""
order = (await db.execute(select(Order).where(
Order.company_id == user.company_id, Order.order_no == order_no))).scalar_one_or_none()
if order is None:
return fail(f"未找到订单 {order_no}")
from huamenchuang.api.orders import _to_out
return ok(data={**_to_out(order),
"processes": [{"id": p.id, "name": p.name, "status": p.status,
"finish_time": str(p.finish_time) if p.finish_time else None}
for p in order.processes]})
# ---------------- 车间看板 ----------------
@router.get("/board")
async def board(user: CurrentUser, db: DbSession):
"""跟单中心看板:在产订单按进度节点分组。"""
orders = list((await db.execute(select(Order).where(
Order.company_id == user.company_id,
Order.status.in_([Order.STATUS_FORMAL, Order.STATUS_PRODUCING]))
.order_by(Order.id.desc()).limit(50))).scalars())
# 预加载全部相关进度
oids = [o.id for o in orders]
procs: dict[int, list[OrderProcess]] = {oid: [] for oid in oids}
if oids:
from sqlalchemy import or_
rows = (await db.execute(select(OrderProcess).where(or_(
*[OrderProcess.order_id == oid for oid in oids])))).scalars()
for p in rows:
procs.setdefault(p.order_id, []).append(p)
columns: dict[str, list] = {name: [] for name in DEFAULT_PROCESSES}
for o in orders:
pl = sorted(procs.get(o.id, []), key=lambda x: x.sort)
# 当前环节 = 第一个未完成节点
current = next((p.name for p in pl if p.status != 2), "完工")
columns.setdefault(current, []).append(
{"id": o.id, "order_no": o.order_no, "customer_name": o.customer_name,
"money": float(o.money), "status_label": o.status_label})
return ok(data={"columns": columns, "order_count": len(orders)})
+4
View File
@@ -22,8 +22,10 @@ from huamenchuang.models.extend import (
Device,
OrderFormat,
PayOrder,
ProcessAssignment,
ShareItem,
SmsCode,
Staff,
SunroomDesign,
)
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
@@ -47,12 +49,14 @@ __all__ = [
"OrderProcess",
"PayOrder",
"PriceScript",
"ProcessAssignment",
"Product",
"ProductCatelog",
"Quotation",
"ReceiptAccount",
"ShareItem",
"SmsCode",
"Staff",
"SunroomDesign",
"StockMove",
"User",
+32
View File
@@ -119,6 +119,38 @@ class SunroomDesign(Base):
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class Staff(Base):
"""车间员工(对齐目标站 manage/workshop/staff:名册与派工)。"""
__tablename__ = "staff"
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))
phone: Mapped[str] = mapped_column(String(20), default="")
# 角色: 下料/组装/玻璃/打胶/五金/质检/完工(与订单进度节点同名)
role: Mapped[str] = mapped_column(String(16), default="下料", index=True)
status: Mapped[int] = mapped_column(default=1) # 1在职 0离职
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class ProcessAssignment(Base):
"""派工记录(订单进度节点 × 员工,staffHistory 语义)。"""
__tablename__ = "process_assignments"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
order_id: Mapped[int] = mapped_column(index=True)
order_no: Mapped[str] = mapped_column(String(32), default="")
process_name: Mapped[str] = mapped_column(String(16))
staff_id: Mapped[int] = mapped_column(default=0)
staff_name: Mapped[str] = mapped_column(String(64), default="")
note: Mapped[str] = mapped_column(String(255), default="")
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class SmsCode(Base):
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
@@ -104,3 +104,91 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
"kerf_mm": kerf,
},
}
def optimize_1d_fine(demands: list[BarDemand], stocks: list[StockBar],
kerf: int = KERF_DEFAULT) -> dict:
"""详细优化(对齐源站 optimize1d-ai「详细优化」语义):BFD 最佳适应递减 + 余料均衡二次调整。
相比快速优化(FFD):每件放入"装下后剩余最小"的原料,显著减少大余料浪费;
再做一轮跨原料的余料均衡交换(把某根的最后一件换到余料更贴合的原料)。
"""
pieces: list[CutPiece] = []
for d in demands:
pieces.extend(CutPiece(length=d.length, label=d.label) for _ in range(max(0, d.qty)))
pieces.sort(key=lambda p: p.length, reverse=True)
avail: list[dict] = []
for s in stocks:
for _ in range(max(0, s.qty)):
avail.append({"length": s.length, "label": s.label, "pieces": [], "used": 0})
unplaced: list[CutPiece] = []
for piece in pieces:
# BFD: 选装下后剩余最小的原料
best, best_remain = None, None
for bar in avail:
remain = bar["length"] - bar["used"]
cost = piece.length + (kerf if bar["pieces"] else 0)
if remain >= cost:
r = remain - cost
if best is None or r < best_remain:
best, best_remain = bar, r
if best is not None:
best["pieces"].append(piece)
best["used"] += piece.length + (kerf if len(best["pieces"]) > 1 else 0)
else:
unplaced.append(piece)
# 余料均衡(有界 2 轮):把"仅一件原料"上的件移到另一根更贴合的原料,减少总浪费
for _round in range(2):
moved = False
for src in avail:
if len(src["pieces"]) != 1:
continue
last = src["pieces"][0]
src_used = last.length # 单件无锯口
for dst in avail:
if dst is src or not dst["pieces"]:
continue
cost = last.length + kerf
if dst["length"] - dst["used"] >= cost:
new_dst_remain = dst["length"] - dst["used"] - cost
if new_dst_remain < src["length"] - src_used:
dst["pieces"].append(last)
dst["used"] += cost
src["pieces"].pop()
src["used"] = 0
moved = True
break
if moved:
break
if not moved:
break
plans = [
CutPlan(stock_length=b["length"], stock_label=b["label"],
pieces=b["pieces"], waste=b["length"] - b["used"])
for b in avail if b["pieces"]
]
total_demand = sum(p.length for p in pieces)
placed_demand = total_demand - sum(p.length for p in unplaced)
total_stock = sum(p.stock_length for p in plans)
return {
"plans": [
{"stock_length": p.stock_length, "stock_label": p.stock_label,
"pieces": [{"length": c.length, "label": c.label} for c in p.pieces],
"waste": p.waste}
for p in plans
],
"unplaced": [{"length": c.length, "label": c.label} for c in unplaced],
"stats": {
"total_pieces": len(pieces),
"placed_pieces": len(pieces) - len(unplaced),
"stock_bars_used": len(plans),
"total_demand_mm": total_demand,
"total_stock_mm": total_stock,
"utilization": round(placed_demand / total_stock, 4) if total_stock else 0,
"kerf_mm": kerf,
},
}