feat: P1批次3 生产域(车间管理/扫码跟踪/双通道优化/订单利润/条码打印)
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# CHANGELOG
|
# 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 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)
|
- [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)
|
||||||
|
|||||||
Generated
+35
@@ -12,12 +12,14 @@
|
|||||||
"@types/three": "^0.186.0",
|
"@types/three": "^0.186.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"element-plus": "^2.9.0",
|
"element-plus": "^2.9.0",
|
||||||
|
"jsbarcode": "^3.12.3",
|
||||||
"pinia": "^2.2.0",
|
"pinia": "^2.2.0",
|
||||||
"three": "^0.186.0",
|
"three": "^0.186.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^4.4.0"
|
"vue-router": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsbarcode": "^3.11.4",
|
||||||
"@vitejs/plugin-vue": "^5.1.0",
|
"@vitejs/plugin-vue": "^5.1.0",
|
||||||
"puppeteer-core": "^25.11.0",
|
"puppeteer-core": "^25.11.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
@@ -979,6 +981,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/lodash": {
|
||||||
"version": "4.17.25",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
|
||||||
@@ -994,6 +1006,16 @@
|
|||||||
"@types/lodash": "*"
|
"@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": {
|
"node_modules/@types/stats.js": {
|
||||||
"version": "0.17.4",
|
"version": "0.17.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
@@ -1774,6 +1796,12 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||||
@@ -2120,6 +2148,13 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"@types/three": "^0.186.0",
|
"@types/three": "^0.186.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"element-plus": "^2.9.0",
|
"element-plus": "^2.9.0",
|
||||||
|
"jsbarcode": "^3.12.3",
|
||||||
"pinia": "^2.2.0",
|
"pinia": "^2.2.0",
|
||||||
"three": "^0.186.0",
|
"three": "^0.186.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^4.4.0"
|
"vue-router": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsbarcode": "^3.11.4",
|
||||||
"@vitejs/plugin-vue": "^5.1.0",
|
"@vitejs/plugin-vue": "^5.1.0",
|
||||||
"puppeteer-core": "^25.11.0",
|
"puppeteer-core": "^25.11.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const router = createRouter({
|
|||||||
{ path: 'ai_render', component: () => import('../views/AIRenderView.vue'), meta: { title: 'AI 炫图' } },
|
{ path: 'ai_render', component: () => import('../views/AIRenderView.vue'), meta: { title: 'AI 炫图' } },
|
||||||
{ path: 'threed', component: () => import('../views/RenderView.vue'), meta: { title: '3D 展示' } },
|
{ path: 'threed', component: () => import('../views/RenderView.vue'), meta: { title: '3D 展示' } },
|
||||||
{ path: 'printer', component: () => import('../views/PrinterView.vue'), meta: { title: '打印中心' } },
|
{ 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: 'cut_solution', component: () => import('../views/CutSolutionView.vue'), meta: { title: '下料方案' } },
|
||||||
{ path: 'devices', component: () => import('../views/DevicesView.vue'), meta: { title: '设备管理' } },
|
{ path: 'devices', component: () => import('../views/DevicesView.vue'), meta: { title: '设备管理' } },
|
||||||
{ path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } },
|
{ path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } },
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<el-menu-item index="/measure"><el-icon><ScaleToOriginal /></el-icon>量尺管理</el-menu-item>
|
<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="/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="/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="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item>
|
||||||
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
|
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
|
||||||
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
|
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
<template>
|
<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-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card header="型材需求(每件长度 × 数量)">
|
<el-card header="型材需求(每件长度 × 数量)">
|
||||||
@@ -59,6 +68,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</template>
|
</template>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 demands = reactive([{ label: '边框', length: 1200, qty: 3 }, { label: '中梃', length: 800, qty: 2 }])
|
||||||
const stocks = reactive([{ label: '6米型材', length: 6000, qty: 2 }])
|
const stocks = reactive([{ label: '6米型材', length: 6000, qty: 2 }])
|
||||||
const kerf = ref(5)
|
const kerf = ref(5)
|
||||||
|
const method = ref<'fast' | 'fine'>('fast')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const result = ref<any>(null)
|
const result = ref<any>(null)
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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)}%`)
|
ElMessage.success(`优化完成,利用率 ${(result.value.stats.utilization * 100).toFixed(1)}%`)
|
||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
}
|
}
|
||||||
@@ -83,6 +94,7 @@ async function run() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
|
.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; }
|
.result-head { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
|
||||||
.util { font-size: 20px; font-weight: 700; }
|
.util { font-size: 20px; font-weight: 700; }
|
||||||
.util-label { color: #909399; }
|
.util-label { color: #909399; }
|
||||||
|
|||||||
@@ -49,6 +49,9 @@
|
|||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
<p v-else-if="b.type === 'sign'" class="sign">客户签字:____________ 日期:____________</p>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-else description="选择订单后预览打印" />
|
<el-empty v-else description="选择订单后预览打印" />
|
||||||
@@ -114,7 +117,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { api } from '../api'
|
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) },
|
created_at: { label: '下单时间', render: o => String(o.created_at ?? '').slice(0, 10) },
|
||||||
note: { label: '备注', render: o => o.note || '-' },
|
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 orders = ref<any[]>([])
|
||||||
const orderId = ref<number | undefined>()
|
const orderId = ref<number | undefined>()
|
||||||
@@ -179,6 +182,10 @@ async function refreshBom() {
|
|||||||
|
|
||||||
function onFormatChange() { refreshBom() }
|
function onFormatChange() { refreshBom() }
|
||||||
|
|
||||||
|
// 模板含条码块时渲染条码
|
||||||
|
watch([() => current.value?.id, () => formatId.value], () => renderBarcodes())
|
||||||
|
watch(() => fmtVisible.value, v => { if (!v) renderBarcodes() })
|
||||||
|
|
||||||
function print() { window.print() }
|
function print() { window.print() }
|
||||||
|
|
||||||
// ---- 模板管理 ----
|
// ---- 模板管理 ----
|
||||||
@@ -197,10 +204,26 @@ function addBlock(t: string) {
|
|||||||
image: { type: 'image' },
|
image: { type: 'image' },
|
||||||
table: { type: 'table', kind: 'process' },
|
table: { type: 'table', kind: 'process' },
|
||||||
sign: { type: 'sign' },
|
sign: { type: 'sign' },
|
||||||
|
barcode: { type: 'barcode' },
|
||||||
}
|
}
|
||||||
fmtForm.blocks.push(JSON.parse(JSON.stringify(seed[t])))
|
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) {
|
function move(idx: number | string, dir: number) {
|
||||||
const i = Number(idx)
|
const i = Number(idx)
|
||||||
const arr = fmtForm.blocks
|
const arr = fmtForm.blocks
|
||||||
|
|||||||
@@ -39,6 +39,31 @@
|
|||||||
<el-tag v-for="s in statusDist" :key="s.status" style="margin:2px">
|
<el-tag v-for="s in statusDist" :key="s.status" style="margin:2px">
|
||||||
{{ statusLabel(s.status) }}:{{ s.count }}</el-tag>
|
{{ statusLabel(s.status) }}:{{ s.count }}</el-tag>
|
||||||
</el-space>
|
</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>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -78,6 +103,14 @@ async function load() {
|
|||||||
|
|
||||||
function exportExcel() { apiDownload('/api/reports/excel-statistics', 'statistics.xlsx') }
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -4,7 +4,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from huamenchuang.core.deps import CurrentUser
|
from huamenchuang.core.deps import CurrentUser
|
||||||
from huamenchuang.core.response import fail, ok
|
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=["优化下料"])
|
router = APIRouter(prefix="/optimize1d", tags=["优化下料"])
|
||||||
|
|
||||||
@@ -13,16 +13,20 @@ class OptimizeRequest(BaseModel):
|
|||||||
demands: list[BarDemand] = Field(description="型材需求 [{length,qty,label}]")
|
demands: list[BarDemand] = Field(description="型材需求 [{length,qty,label}]")
|
||||||
stocks: list[StockBar] = Field(description="库存原料 [{length,qty,label}]")
|
stocks: list[StockBar] = Field(description="库存原料 [{length,qty,label}]")
|
||||||
kerf: int = Field(default=5, ge=0, le=50, description="锯口损耗(mm)")
|
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("")
|
@router.post("")
|
||||||
async def optimize(body: OptimizeRequest, user: CurrentUser):
|
async def optimize(body: OptimizeRequest, user: CurrentUser):
|
||||||
"""一维优化下料(FFD 首递减算法)。"""
|
"""一维优化下料(双通道:快速 FFD / 详细 BFD,对齐源站双通道语义)。"""
|
||||||
if not body.demands or not body.stocks:
|
if not body.demands or not body.stocks:
|
||||||
return fail("需求和库存均不能为空")
|
return fail("需求和库存均不能为空")
|
||||||
total_demand = sum(d.length * d.qty for d in body.demands)
|
total_demand = sum(d.length * d.qty for d in body.demands)
|
||||||
total_stock = sum(s.length * s.qty for s in body.stocks)
|
total_stock = sum(s.length * s.qty for s in body.stocks)
|
||||||
if total_demand > total_stock:
|
if total_demand > total_stock:
|
||||||
return fail(f"库存不足: 需求 {total_demand}mm > 库存 {total_stock}mm")
|
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="优化完成")
|
return ok(data=result, message="优化完成")
|
||||||
|
|||||||
@@ -100,3 +100,51 @@ async def excel_statistics(user: CurrentUser, db: DbSession):
|
|||||||
content = statistics_workbook(summary, receipts)
|
content = statistics_workbook(summary, receipts)
|
||||||
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
headers={"Content-Disposition": "attachment; filename=statistics.xlsx"})
|
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,
|
||||||
|
}})
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from huamenchuang.api import (
|
|||||||
sunroom,
|
sunroom,
|
||||||
warehouse,
|
warehouse,
|
||||||
windoor,
|
windoor,
|
||||||
|
workshop,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api")
|
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(shares.router)
|
||||||
api_router.include_router(recommend.router)
|
api_router.include_router(recommend.router)
|
||||||
api_router.include_router(sunroom.router)
|
api_router.include_router(sunroom.router)
|
||||||
|
api_router.include_router(workshop.router)
|
||||||
api_router.include_router(warehouse.router)
|
api_router.include_router(warehouse.router)
|
||||||
api_router.include_router(business.router)
|
api_router.include_router(business.router)
|
||||||
api_router.include_router(platform.router)
|
api_router.include_router(platform.router)
|
||||||
|
|||||||
@@ -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)})
|
||||||
@@ -22,8 +22,10 @@ from huamenchuang.models.extend import (
|
|||||||
Device,
|
Device,
|
||||||
OrderFormat,
|
OrderFormat,
|
||||||
PayOrder,
|
PayOrder,
|
||||||
|
ProcessAssignment,
|
||||||
ShareItem,
|
ShareItem,
|
||||||
SmsCode,
|
SmsCode,
|
||||||
|
Staff,
|
||||||
SunroomDesign,
|
SunroomDesign,
|
||||||
)
|
)
|
||||||
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
|
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
|
||||||
@@ -47,12 +49,14 @@ __all__ = [
|
|||||||
"OrderProcess",
|
"OrderProcess",
|
||||||
"PayOrder",
|
"PayOrder",
|
||||||
"PriceScript",
|
"PriceScript",
|
||||||
|
"ProcessAssignment",
|
||||||
"Product",
|
"Product",
|
||||||
"ProductCatelog",
|
"ProductCatelog",
|
||||||
"Quotation",
|
"Quotation",
|
||||||
"ReceiptAccount",
|
"ReceiptAccount",
|
||||||
"ShareItem",
|
"ShareItem",
|
||||||
"SmsCode",
|
"SmsCode",
|
||||||
|
"Staff",
|
||||||
"SunroomDesign",
|
"SunroomDesign",
|
||||||
"StockMove",
|
"StockMove",
|
||||||
"User",
|
"User",
|
||||||
|
|||||||
@@ -119,6 +119,38 @@ class SunroomDesign(Base):
|
|||||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
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):
|
class SmsCode(Base):
|
||||||
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
|
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
|
||||||
|
|
||||||
|
|||||||
@@ -104,3 +104,91 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
|
|||||||
"kerf_mm": kerf,
|
"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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user