feat: P0-4 按库存优化+切割准备清单(缺料告警)
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-09-25 13:00] FEAT: v2.23 P0-4 按库存优化+切割准备。①cut_solution 加 use_stock/stock_length 参数: use_stock=true 从仓库 profile 库存(单根长度>0)取原料 FFD 优化, 需求超库存输出 shortage 缺料清单(不拦截); 非库存模式 stock_length 可配; ②新增 preparation 切割准备/领料清单(按原料聚合根数/切割件数/余料)随方案保存; ③下料方案页加库存开关+标准长度输入+领料单表格+缺料清单+打印; vue-tsc 0 错+构建通过; API 实测合同3(15件/利用率87.4%/领料6米5根) (Files: src/huamenchuang/api/platform.py, frontend/src/views/CutSolutionView.vue, doc/API.md)
|
||||||
|
|
||||||
- [2026-09-25 12:20] FEAT: v2.22 P0-3 打印模板体系。①OrderFormat 模型(blocks 区块 JSON: title/info/image/table/sign)+种子 2 套默认模板; ②/api/order-formats CRUD(区块类型校验+is_default 唯一默认); ③打印中心重构为模板驱动: A4 预览按 blocks 渲染(BOM 块自动拉取 /orders/{id}/bom), 模板管理对话框(区块增删/上下移/title 文本/info 字段多选/table kind 选择); 修复 move(i) v-for 索引 string|number 类型; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/order_format.py, src/huamenchuang/api/router.py, src/huamenchuang/scripts/seed.py, frontend/src/views/PrinterView.vue, doc/API.md)
|
- [2026-09-25 12:20] FEAT: v2.22 P0-3 打印模板体系。①OrderFormat 模型(blocks 区块 JSON: title/info/image/table/sign)+种子 2 套默认模板; ②/api/order-formats CRUD(区块类型校验+is_default 唯一默认); ③打印中心重构为模板驱动: A4 预览按 blocks 渲染(BOM 块自动拉取 /orders/{id}/bom), 模板管理对话框(区块增删/上下移/title 文本/info 字段多选/table kind 选择); 修复 move(i) v-for 索引 string|number 类型; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/order_format.py, src/huamenchuang/api/router.py, src/huamenchuang/scripts/seed.py, frontend/src/views/PrinterView.vue, doc/API.md)
|
||||||
|
|
||||||
- [2026-09-25 11:40] FEAT: v2.21 P0-2 订单 BOM 计算单+Excel 导出。①quote_service.bom() 从 drawing_data 重放算料输出型材下料清单(calcbomOrder 语义); ②services/excel_service.py 通用 xlsx 工具(标题/带样式表格/列宽)+order_workbook 订单导出(信息+型材清单+玻璃五金汇总+签审行); ③GET /orders/{id}/bom + GET /orders/{id}/export(attachment 下载); ④前端订单详情抽屉加"BOM 单"弹窗(汇总+下料表+合计米数)+导出 Excel(apiDownload blob 带 token); openpyxl 入依赖; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/api/orders.py, frontend/src/api/index.ts, frontend/src/views/OrdersView.vue, doc/API.md)
|
- [2026-09-25 11:40] FEAT: v2.21 P0-2 订单 BOM 计算单+Excel 导出。①quote_service.bom() 从 drawing_data 重放算料输出型材下料清单(calcbomOrder 语义); ②services/excel_service.py 通用 xlsx 工具(标题/带样式表格/列宽)+order_workbook 订单导出(信息+型材清单+玻璃五金汇总+签审行); ③GET /orders/{id}/bom + GET /orders/{id}/export(attachment 下载); ④前端订单详情抽屉加"BOM 单"弹窗(汇总+下料表+合计米数)+导出 Excel(apiDownload blob 带 token); openpyxl 入依赖; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/api/orders.py, frontend/src/api/index.ts, frontend/src/views/OrdersView.vue, doc/API.md)
|
||||||
|
|||||||
@@ -405,3 +405,12 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|
|||||||
| 删除模板 | DELETE | `/api/order-formats/{id}` | 路径 | |
|
| 删除模板 | DELETE | `/api/order-formats/{id}` | 路径 | |
|
||||||
|
|
||||||
种子模板 2 套:标准生产订单(默认)、生产订单+BOM 下料单。
|
种子模板 2 套:标准生产订单(默认)、生产订单+BOM 下料单。
|
||||||
|
|
||||||
|
### 12.18 合同下料方案升级(v2.20,P0-按库存优化)
|
||||||
|
|
||||||
|
`POST /api/cut_solution` 请求扩展:`{contract_id, kerf, use_stock?, stock_length?}`。
|
||||||
|
|
||||||
|
- `use_stock=true`:从仓库实际库存(material=profile 且 length>0 且 count>0)取原料优化;需求超出库存时输出 `shortage[]` 缺料清单(length/label/qty),不拦截计算
|
||||||
|
- `use_stock=false`:按 `stock_length`(默认 6000mm)虚拟原料,数量按需求自动估算
|
||||||
|
- 响应/保存均新增:`result`(完整优化方案)、`preparation[]`(切割准备/领料清单:原料×根数×切割件数×余料)、`shortage[]`(按库存模式的缺料清单)
|
||||||
|
- `GET /api/cut_solution/{contract_id}` 返回完整保存方案(含 result/preparation/shortage)
|
||||||
|
|||||||
@@ -1,18 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-card>
|
<el-card>
|
||||||
<div class="toolbar">
|
<div class="toolbar no-print">
|
||||||
<el-select v-model="contractId" filterable placeholder="选择合同" style="width:300px">
|
<el-select v-model="contractId" filterable placeholder="选择合同" style="width:300px">
|
||||||
<el-option v-for="c in contracts" :key="c.id" :label="`${c.contract_no} ${c.customer_name || ''}`" :value="c.id" />
|
<el-option v-for="c in contracts" :key="c.id" :label="`${c.contract_no} ${c.customer_name || ''}`" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<span>锯口</span>
|
<span>锯口</span>
|
||||||
<el-input-number v-model="kerf" :min="0" :max="50" size="small" controls-position="right" style="width:90px" />
|
<el-input-number v-model="kerf" :min="0" :max="50" size="small" controls-position="right" style="width:90px" />
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<el-checkbox v-model="useStock">使用仓库库存原料</el-checkbox>
|
||||||
|
<template v-if="!useStock">
|
||||||
|
<span>标准原料长度</span>
|
||||||
|
<el-input-number v-model="stockLength" :min="1000" :max="12000" :step="500" size="small"
|
||||||
|
controls-position="right" style="width:110px" />
|
||||||
|
</template>
|
||||||
<el-button type="primary" :loading="loading" :disabled="!contractId" @click="run">生成下料方案</el-button>
|
<el-button type="primary" :loading="loading" :disabled="!contractId" @click="run">生成下料方案</el-button>
|
||||||
|
<el-button v-if="result" type="success" plain @click="print">打印方案</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="result">
|
<template v-if="result">
|
||||||
<el-alert type="success" :closable="false" show-icon
|
<el-alert type="success" :closable="false" show-icon class="no-print"
|
||||||
:title="`合同 ${contractNo} · ${result.order_count} 个订单 · 利用率 ${(result.stats.utilization * 100).toFixed(1)}% · 用料 ${result.stats.stock_bars_used} 根`"
|
:title="`合同 ${contractNo} · ${result.order_count} 个订单 · 利用率 ${(result.stats.utilization * 100).toFixed(1)}% · 用料 ${result.stats.stock_bars_used} 根${result.use_stock ? ' · 按仓库库存' : ''}`"
|
||||||
style="margin-bottom:12px" />
|
style="margin-bottom:12px" />
|
||||||
|
|
||||||
|
<!-- 切割准备/领料清单 -->
|
||||||
|
<h4 class="sec-title">切割准备(领料单)<span id="print-title" v-if="false"></span></h4>
|
||||||
|
<el-table :data="result.preparation" border size="small">
|
||||||
|
<el-table-column label="原料" min-width="160">
|
||||||
|
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="bars" label="领料根数" width="100" />
|
||||||
|
<el-table-column prop="pieces" label="切割件数" width="100" />
|
||||||
|
<el-table-column label="合计余料(mm)" width="130">
|
||||||
|
<template #default="{ row }">{{ row.waste }}(均 {{ Math.round(row.waste / row.bars) }})</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 缺料清单(按库存模式) -->
|
||||||
|
<template v-if="result.use_stock && result.shortage?.length">
|
||||||
|
<h4 class="sec-title" style="color:#e6a23c">缺料清单(需求超出库存,需补库)</h4>
|
||||||
|
<el-table :data="result.shortage" border size="small" :row-style="{ background: '#fdf6ec' }">
|
||||||
|
<el-table-column prop="label" label="缺料用途" min-width="140" />
|
||||||
|
<el-table-column prop="length" label="单件长度(mm)" width="120" />
|
||||||
|
<el-table-column prop="qty" label="缺口(件)" width="100" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h4 class="sec-title">切割方案明细</h4>
|
||||||
<el-table :data="result.plans" border size="small">
|
<el-table :data="result.plans" border size="small">
|
||||||
<el-table-column label="原料" width="180">
|
<el-table-column label="原料" width="180">
|
||||||
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
|
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
|
||||||
@@ -25,8 +58,11 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="waste" label="余料mm" width="90" />
|
<el-table-column prop="waste" label="余料mm" width="90" />
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-alert v-if="result.unplaced.length" type="warning" :closable="false"
|
|
||||||
:title="`未排入 ${result.unplaced.length} 件(库存不足)`" style="margin-top:10px" />
|
<template v-if="!result.use_stock && result.unplaced.length">
|
||||||
|
<el-alert type="warning" :closable="false" class="no-print"
|
||||||
|
:title="`未排入 ${result.unplaced.length} 件`" style="margin-top:10px" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -40,19 +76,26 @@ const contracts = ref<any[]>([])
|
|||||||
const contractId = ref<number | undefined>()
|
const contractId = ref<number | undefined>()
|
||||||
const contractNo = ref('')
|
const contractNo = ref('')
|
||||||
const kerf = ref(5)
|
const kerf = ref(5)
|
||||||
|
const useStock = ref(false)
|
||||||
|
const stockLength = ref(6000)
|
||||||
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 {
|
||||||
const d = await api<any>('post', '/api/cut_solution', { contract_id: contractId.value, kerf: kerf.value })
|
const d = await api<any>('post', '/api/cut_solution', {
|
||||||
|
contract_id: contractId.value, kerf: kerf.value,
|
||||||
|
use_stock: useStock.value, stock_length: stockLength.value,
|
||||||
|
})
|
||||||
result.value = d
|
result.value = d
|
||||||
contractNo.value = d.contract_no
|
contractNo.value = d.contract_no
|
||||||
ElMessage.success(`已生成:利用率 ${(d.stats.utilization * 100).toFixed(1)}%`)
|
ElMessage.success(`已生成:利用率 ${(d.stats.utilization * 100).toFixed(1)}%`)
|
||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function print() { window.print() }
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const d = await api<any>('get', '/api/contracts?page=1&page_size=100')
|
const d = await api<any>('get', '/api/contracts?page=1&page_size=100')
|
||||||
contracts.value = d.contracts?.data ?? []
|
contracts.value = d.contracts?.data ?? []
|
||||||
@@ -60,5 +103,12 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
|
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.sec-title { margin: 16px 0 8px; color: #303133; }
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
body * { visibility: hidden; }
|
||||||
|
.el-card, .el-card * { visibility: visible; }
|
||||||
|
.el-card { position: absolute; left: 0; top: 0; width: 100%; box-shadow: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -54,45 +54,103 @@ async def save_render_setting(body: RenderSettingBody, user: CurrentUser, db: Db
|
|||||||
class CutSolutionBody(BaseModel):
|
class CutSolutionBody(BaseModel):
|
||||||
contract_id: int
|
contract_id: int
|
||||||
kerf: int = Field(default=5, ge=0, le=50)
|
kerf: int = Field(default=5, ge=0, le=50)
|
||||||
|
use_stock: bool = Field(default=False, description="使用仓库实际库存原料优化(不足部分输出缺口清单)")
|
||||||
|
stock_length: int = Field(default=6000, ge=1000, le=12000, description="非库存模式的标准原料长度(mm)")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cut_solution")
|
def _contract_demands(orders) -> dict[tuple, int]:
|
||||||
async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
|
"""汇总订单图纸的型材需求 {(length, note): qty}。"""
|
||||||
"""合同下料方案:汇总合同下订单的算料需求,执行一维优化并保存参数。"""
|
from huamenchuang.core.drawing import parse_drawing
|
||||||
contract = await db.get(Contract, body.contract_id)
|
from huamenchuang.services.quote_service import takeoff
|
||||||
if contract is None or contract.company_id != user.company_id:
|
demands: dict[tuple, int] = {}
|
||||||
return fail("合同不存在")
|
|
||||||
stmt = select(Order).where(Order.contract_id == body.contract_id,
|
|
||||||
Order.company_id == user.company_id)
|
|
||||||
orders = list((await db.execute(stmt)).scalars())
|
|
||||||
demands: dict[tuple, float] = {}
|
|
||||||
for o in orders:
|
for o in orders:
|
||||||
if not o.drawing_data:
|
if not o.drawing_data:
|
||||||
continue
|
continue
|
||||||
from huamenchuang.core.drawing import parse_drawing
|
|
||||||
from huamenchuang.services.quote_service import takeoff
|
|
||||||
t = takeoff(parse_drawing(o.drawing_data))
|
t = takeoff(parse_drawing(o.drawing_data))
|
||||||
for b in t.bars:
|
for b in t.bars:
|
||||||
if b.kind == "glass":
|
if b.kind == "glass":
|
||||||
continue
|
continue
|
||||||
key = (b.length, b.note)
|
key = (b.length, b.note)
|
||||||
demands[key] = demands.get(key, 0) + b.qty
|
demands[key] = demands.get(key, 0) + b.qty
|
||||||
|
return demands
|
||||||
|
|
||||||
|
|
||||||
|
async def _stock_bars(db, company_id: int) -> list:
|
||||||
|
"""仓库型材库存 → StockBar 列表(按单根长度分组)。"""
|
||||||
|
from huamenchuang.models.contract import WarehouseItem
|
||||||
|
from huamenchuang.services.optimize_service import StockBar
|
||||||
|
stmt = select(WarehouseItem).where(
|
||||||
|
WarehouseItem.company_id == company_id,
|
||||||
|
WarehouseItem.material == "profile",
|
||||||
|
WarehouseItem.length > 0,
|
||||||
|
WarehouseItem.count > 0)
|
||||||
|
items = list((await db.execute(stmt)).scalars())
|
||||||
|
return [StockBar(length=int(i.length), qty=int(float(i.count)), label=i.name or f"{int(i.length)}mm型材")
|
||||||
|
for i in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cut_solution")
|
||||||
|
async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
|
||||||
|
"""合同下料方案:汇总合同下订单的算料需求,执行一维优化并保存(可选按库存)。"""
|
||||||
|
contract = await db.get(Contract, body.contract_id)
|
||||||
|
if contract is None or contract.company_id != user.company_id:
|
||||||
|
return fail("合同不存在")
|
||||||
|
stmt = select(Order).where(Order.contract_id == body.contract_id,
|
||||||
|
Order.company_id == user.company_id)
|
||||||
|
orders = list((await db.execute(stmt)).scalars())
|
||||||
|
demands = _contract_demands(orders)
|
||||||
if not demands:
|
if not demands:
|
||||||
return fail("该合同下没有可算料的订单图纸")
|
return fail("该合同下没有可算料的订单图纸")
|
||||||
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
|
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
|
||||||
dem = [BarDemand(length=k[0], qty=int(v), label=k[1]) for k, v in demands.items()]
|
dem = [BarDemand(length=k[0], qty=int(v), label=k[1]) for k, v in demands.items()]
|
||||||
stocks = [StockBar(length=6000, qty=max(1, sum(d.qty for d in dem) // 3 + 1), label="6米型材")]
|
if body.use_stock:
|
||||||
|
stocks = await _stock_bars(db, user.company_id)
|
||||||
|
if not stocks:
|
||||||
|
return fail("仓库中没有可用的型材库存(需 material=profile 且 length>0)")
|
||||||
|
else:
|
||||||
|
total_pieces = sum(d.qty for d in dem)
|
||||||
|
stocks = [StockBar(length=body.stock_length, qty=max(1, total_pieces // 3 + 1),
|
||||||
|
label=f"{body.stock_length}mm型材")]
|
||||||
result = optimize_1d(dem, stocks, body.kerf)
|
result = optimize_1d(dem, stocks, body.kerf)
|
||||||
|
# 按库存模式:缺料清单 = 未能排入的件(需求超出库存)
|
||||||
|
shortage = []
|
||||||
|
if body.use_stock and result["unplaced"]:
|
||||||
|
agg: dict[tuple, int] = {}
|
||||||
|
for u in result["unplaced"]:
|
||||||
|
agg[(u["length"], u["label"])] = agg.get((u["length"], u["label"]), 0) + 1
|
||||||
|
shortage = [{"length": k[0], "label": k[1], "qty": v} for k, v in
|
||||||
|
sorted(agg.items(), key=lambda x: -x[0][0])]
|
||||||
|
# 领料/切割准备清单:按原料长度聚合根数
|
||||||
|
preparation = _preparation(result)
|
||||||
|
payload = {"demands": [[k[0], int(v), k[1]] for k, v in demands.items()],
|
||||||
|
"kerf": body.kerf, "use_stock": body.use_stock,
|
||||||
|
"stock_length": body.stock_length, "result": result,
|
||||||
|
"shortage": shortage, "preparation": preparation}
|
||||||
await _save_param(db, user.company_id, f"cut_solution_{body.contract_id}",
|
await _save_param(db, user.company_id, f"cut_solution_{body.contract_id}",
|
||||||
__import__("json").dumps({"demands": [[k[0], int(v), k[1]] for k, v in demands.items()],
|
__import__("json").dumps(payload, ensure_ascii=False))
|
||||||
"kerf": body.kerf}, ensure_ascii=False))
|
return ok(data={"contract_no": contract.contract_no, "order_count": len(orders),
|
||||||
return ok(data={"contract_no": contract.contract_no, "order_count": len(orders), **result},
|
"use_stock": body.use_stock, **result,
|
||||||
|
"shortage": shortage, "preparation": preparation},
|
||||||
message="下料方案已生成")
|
message="下料方案已生成")
|
||||||
|
|
||||||
|
|
||||||
|
def _preparation(result: dict) -> list[dict]:
|
||||||
|
"""切割准备清单:按原料(长度,标签)聚合根数 + 每根切割件数/余料。"""
|
||||||
|
agg: dict[tuple, dict] = {}
|
||||||
|
for plan in result["plans"]:
|
||||||
|
key = (plan["stock_length"], plan["stock_label"])
|
||||||
|
if key not in agg:
|
||||||
|
agg[key] = {"stock_length": plan["stock_length"], "stock_label": plan["stock_label"],
|
||||||
|
"bars": 0, "pieces": 0, "waste": 0}
|
||||||
|
agg[key]["bars"] += 1
|
||||||
|
agg[key]["pieces"] += len(plan["pieces"])
|
||||||
|
agg[key]["waste"] += plan["waste"]
|
||||||
|
return sorted(agg.values(), key=lambda x: -x["stock_length"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cut_solution/{contract_id}")
|
@router.get("/cut_solution/{contract_id}")
|
||||||
async def get_cut_solution(contract_id: int, user: CurrentUser, db: DbSession):
|
async def get_cut_solution(contract_id: int, user: CurrentUser, db: DbSession):
|
||||||
"""读取已保存的下料方案参数。"""
|
"""读取已保存的下料方案(含优化结果/缺口/领料清单)。"""
|
||||||
p = await _get_param(db, user.company_id, f"cut_solution_{contract_id}")
|
p = await _get_param(db, user.company_id, f"cut_solution_{contract_id}")
|
||||||
if p is None:
|
if p is None:
|
||||||
return fail("尚未生成下料方案")
|
return fail("尚未生成下料方案")
|
||||||
|
|||||||
Reference in New Issue
Block a user