Compare commits
2 Commits
eefd3d303c
...
992e08aee9
| Author | SHA1 | Date | |
|---|---|---|---|
| 992e08aee9 | |||
| f27488e693 |
@ -1,7 +1,6 @@
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from app.services.bom_service import BomService, _cache_delete
|
from app.services.bom_service import BomService, _cache_delete
|
||||||
from app.services.cascade_service import CascadeService
|
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
from app.models.bom import BomTable
|
from app.models.bom import BomTable
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@ -383,50 +382,3 @@ def get_bom_parents():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
|
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
|
||||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
|
|
||||||
|
|
||||||
@bom_bp.route('/cascade-inventory', methods=['GET'])
|
|
||||||
@jwt_required()
|
|
||||||
@permission_required('bom_manage')
|
|
||||||
def cascade_inventory():
|
|
||||||
"""
|
|
||||||
动态级联扣减领料接口。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
bom_no BOM 编号(必填)
|
|
||||||
order_qty 订单需求总量(必填,float)
|
|
||||||
version BOM 版本(可选,默认取最新)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
级联展开后的分层领料清单,含 allocated / shortage 缺口分析
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
bom_no = request.args.get('bom_no', '').strip()
|
|
||||||
order_qty_str = request.args.get('order_qty', '').strip()
|
|
||||||
version = request.args.get('version') or None
|
|
||||||
|
|
||||||
if not bom_no:
|
|
||||||
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
|
|
||||||
if not order_qty_str:
|
|
||||||
return jsonify({'code': 400, 'msg': 'order_qty 不能为空'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
order_qty = float(order_qty_str)
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({'code': 400, 'msg': 'order_qty 必须是数字'}), 400
|
|
||||||
|
|
||||||
if order_qty <= 0:
|
|
||||||
return jsonify({'code': 400, 'msg': 'order_qty 必须大于 0'}), 400
|
|
||||||
|
|
||||||
result = CascadeService.compute_cascade_result(bom_no, order_qty, version=version)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'code': 200,
|
|
||||||
'msg': 'success',
|
|
||||||
'data': result
|
|
||||||
})
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.error(f'级联扣减领料失败: {str(e)}')
|
|
||||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
|
||||||
|
|||||||
@ -1,197 +0,0 @@
|
|||||||
from app.extensions import db
|
|
||||||
from app.models.bom import BomTable
|
|
||||||
from app.models.base import MaterialBase
|
|
||||||
from app.models.inbound.buy import StockBuy
|
|
||||||
from app.models.inbound.semi import StockSemi
|
|
||||||
from app.models.inbound.product import StockProduct
|
|
||||||
from sqlalchemy import func
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_available_qty(material_type: str, base_id: int) -> float:
|
|
||||||
"""
|
|
||||||
根据 material_type 查询对应库存表的 available_quantity。
|
|
||||||
若查不到记录或无可用量,返回 0.0。
|
|
||||||
"""
|
|
||||||
qty = 0.0
|
|
||||||
if material_type == 'product':
|
|
||||||
row = db.session.query(
|
|
||||||
func.coalesce(func.sum(StockProduct.available_quantity), 0)
|
|
||||||
).filter(StockProduct.base_id == base_id).scalar()
|
|
||||||
qty = float(row) if row else 0.0
|
|
||||||
elif material_type == 'semi':
|
|
||||||
row = db.session.query(
|
|
||||||
func.coalesce(func.sum(StockSemi.available_quantity), 0)
|
|
||||||
).filter(StockSemi.base_id == base_id).scalar()
|
|
||||||
qty = float(row) if row else 0.0
|
|
||||||
elif material_type == 'buy':
|
|
||||||
row = db.session.query(
|
|
||||||
func.coalesce(func.sum(StockBuy.available_quantity), 0)
|
|
||||||
).filter(StockBuy.base_id == base_id).scalar()
|
|
||||||
qty = float(row) if row else 0.0
|
|
||||||
else:
|
|
||||||
qty = 0.0
|
|
||||||
return qty
|
|
||||||
|
|
||||||
|
|
||||||
def _cascade_allocate(
|
|
||||||
db_session,
|
|
||||||
material_id: int,
|
|
||||||
required_qty: float,
|
|
||||||
result_list: list,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
递归级联扣减领料核心函数。
|
|
||||||
|
|
||||||
算法:
|
|
||||||
1. 根据 material_id 查出 MaterialBase 获取 material_type
|
|
||||||
2. 查对应库存表 available_quantity
|
|
||||||
3. 计算 allocated_qty = min(可用库存, required_qty)
|
|
||||||
4. shortage_qty = required_qty - allocated_qty
|
|
||||||
5. 将结果追加到 result_list
|
|
||||||
6. 若 shortage_qty > 0,递归查 BomTable 展开子件,
|
|
||||||
对每个子件用 shortage_qty * dosage 继续分配
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_session: SQLAlchemy db session
|
|
||||||
material_id: 当前物料 ID
|
|
||||||
required_qty: 当前层级需求数量
|
|
||||||
result_list: 收集结果的列表(就地修改)
|
|
||||||
"""
|
|
||||||
if required_qty <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
material = db_session.query(MaterialBase).filter_by(id=material_id).first()
|
|
||||||
if not material:
|
|
||||||
logger.warning(f"[Cascade] 物料 {material_id} 在 material_base 中不存在,跳过")
|
|
||||||
return
|
|
||||||
|
|
||||||
mat_type = material.material_type or ''
|
|
||||||
available = _get_available_qty(mat_type, material_id)
|
|
||||||
|
|
||||||
allocated_qty = min(available, required_qty)
|
|
||||||
shortage_qty = required_qty - allocated_qty
|
|
||||||
|
|
||||||
result_list.append({
|
|
||||||
'material_id': material_id,
|
|
||||||
'material_name': material.name,
|
|
||||||
'material_type': mat_type,
|
|
||||||
'spec_model': material.spec_model or '',
|
|
||||||
'unit': material.unit or '',
|
|
||||||
'required_qty': required_qty,
|
|
||||||
'available_qty': available,
|
|
||||||
'allocated_qty': allocated_qty,
|
|
||||||
'shortage_qty': shortage_qty,
|
|
||||||
})
|
|
||||||
|
|
||||||
if shortage_qty > 0:
|
|
||||||
child_rows = db_session.query(BomTable).filter(
|
|
||||||
BomTable.parent_id == material_id,
|
|
||||||
BomTable.is_enabled == True,
|
|
||||||
).all()
|
|
||||||
|
|
||||||
for row in child_rows:
|
|
||||||
child_id = row.child_id
|
|
||||||
dosage = float(row.dosage) if row.dosage else 0.0
|
|
||||||
if dosage <= 0:
|
|
||||||
logger.warning(
|
|
||||||
f"[Cascade] 子件 child_id={child_id} dosage={dosage} 无效,跳过"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
child_required_qty = shortage_qty * dosage
|
|
||||||
_cascade_allocate(db_session, child_id, child_required_qty, result_list)
|
|
||||||
|
|
||||||
|
|
||||||
class CascadeService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def compute_cascade_result(bom_no: str, order_qty: float, version: str = None) -> dict:
|
|
||||||
"""
|
|
||||||
根据 bom_no 和订单总量执行级联扣减领料,返回完整的分层结果。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
bom_no: BOM 编号
|
|
||||||
order_qty: 订单需求总量(顶层成品需求数量)
|
|
||||||
version: BOM 版本(可选,默认取最新)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"bom_no": str,
|
|
||||||
"version": str,
|
|
||||||
"parent_id": int,
|
|
||||||
"parent_name": str,
|
|
||||||
"order_qty": float,
|
|
||||||
"total_allocated": float, # 累计已分配量(用于评估满足率)
|
|
||||||
"total_shortage": float, # 最终缺口(顶层 shortage_qty)
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"level": int, # 递归层级(0 为顶层)
|
|
||||||
"material_id": int,
|
|
||||||
"material_name": str,
|
|
||||||
"material_type": str,
|
|
||||||
"spec_model": str,
|
|
||||||
"unit": str,
|
|
||||||
"required_qty": float,
|
|
||||||
"available_qty": float,
|
|
||||||
"allocated_qty": float,
|
|
||||||
"shortage_qty": float,
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
from app.services.bom_service import BomService
|
|
||||||
|
|
||||||
detail = BomService.get_bom_detail(bom_no, version=version)
|
|
||||||
if not detail:
|
|
||||||
raise ValueError(f"BOM {bom_no} 不存在")
|
|
||||||
|
|
||||||
parent_id = detail['parent_id']
|
|
||||||
parent_name = detail.get('parent_name', '')
|
|
||||||
bom_version = detail.get('version', 'V1.0')
|
|
||||||
|
|
||||||
result_list: list = []
|
|
||||||
_cascade_allocate(db.session, parent_id, order_qty, result_list)
|
|
||||||
|
|
||||||
# 汇总顶层成品allocated和shortage
|
|
||||||
parent_item = next(
|
|
||||||
(it for it in result_list if it['material_id'] == parent_id), None
|
|
||||||
)
|
|
||||||
total_allocated = parent_item['allocated_qty'] if parent_item else 0.0
|
|
||||||
total_shortage = parent_item['shortage_qty'] if parent_item else order_qty
|
|
||||||
|
|
||||||
# 按 material_id 合并去重(同一物料被多个父件引用时合并 allocated_qty)
|
|
||||||
merged_map: dict = {}
|
|
||||||
for it in result_list:
|
|
||||||
mid = it['material_id']
|
|
||||||
if mid not in merged_map:
|
|
||||||
merged_map[mid] = {
|
|
||||||
'material_id': it['material_id'],
|
|
||||||
'material_name': it['material_name'],
|
|
||||||
'material_type': it['material_type'],
|
|
||||||
'spec_model': it['spec_model'],
|
|
||||||
'unit': it['unit'],
|
|
||||||
'required_qty': 0.0,
|
|
||||||
'available_qty': it['available_qty'],
|
|
||||||
'allocated_qty': 0.0,
|
|
||||||
'shortage_qty': 0.0,
|
|
||||||
}
|
|
||||||
merged_map[mid]['required_qty'] += it['required_qty']
|
|
||||||
merged_map[mid]['allocated_qty'] += it['allocated_qty']
|
|
||||||
merged_map[mid]['shortage_qty'] += it['shortage_qty']
|
|
||||||
|
|
||||||
merged_items = list(merged_map.values())
|
|
||||||
|
|
||||||
return {
|
|
||||||
'bom_no': bom_no,
|
|
||||||
'version': bom_version,
|
|
||||||
'parent_id': parent_id,
|
|
||||||
'parent_name': parent_name,
|
|
||||||
'order_qty': order_qty,
|
|
||||||
'total_allocated': total_allocated,
|
|
||||||
'total_shortage': total_shortage,
|
|
||||||
'items': merged_items,
|
|
||||||
}
|
|
||||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.39
|
当前版本:V3.40
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@ -527,12 +527,14 @@ const totalExportCount = computed(() => {
|
|||||||
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stock,O(N),无嵌套循环)---
|
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stock,O(N),无嵌套循环)---
|
||||||
const maxBuildableSets = computed(() => {
|
const maxBuildableSets = computed(() => {
|
||||||
if (!currentBomDetail.value?.length) return 0
|
if (!currentBomDetail.value?.length) return 0
|
||||||
return currentBomDetail.value.reduce((minSets, bomItem: any) => {
|
const result = currentBomDetail.value.reduce((minSets, bomItem: any) => {
|
||||||
const dosage = parseFloat(bomItem.dosage) || 0
|
const dosage = parseFloat(bomItem.dosage) || 0
|
||||||
if (dosage <= 0) return minSets
|
if (dosage <= 0) return minSets
|
||||||
const stock = parseFloat(bomItem.current_stock) || 0
|
const stock = parseFloat(bomItem.current_stock) || 0
|
||||||
return Math.min(minSets, Math.floor(stock / dosage))
|
return Math.min(minSets, Math.floor(stock / dosage))
|
||||||
}, Infinity)
|
}, Infinity)
|
||||||
|
|
||||||
|
return result === Infinity ? 0 : result
|
||||||
})
|
})
|
||||||
|
|
||||||
const shortageList = computed(() => {
|
const shortageList = computed(() => {
|
||||||
@ -723,8 +725,8 @@ watch(selectedBomNo, async (newBomNo) => {
|
|||||||
const confirmBomAdd = async () => {
|
const confirmBomAdd = async () => {
|
||||||
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
||||||
|
|
||||||
if (allStockData.value.length === 0) {
|
if (stockList.value.length === 0) {
|
||||||
await ensureAllStockLoaded()
|
await loadStockList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentBomDetail.value.length === 0) {
|
if (currentBomDetail.value.length === 0) {
|
||||||
@ -745,7 +747,7 @@ const confirmBomAdd = async () => {
|
|||||||
const dosage = parseFloat(bomItem.dosage) || 0
|
const dosage = parseFloat(bomItem.dosage) || 0
|
||||||
const needQty = dosage * bomSets.value
|
const needQty = dosage * bomSets.value
|
||||||
|
|
||||||
const stockCandidate = allStockData.value.find(s =>
|
const stockCandidate = stockList.value.find(s =>
|
||||||
(s.base_id && s.base_id == bomItem.child_id)
|
(s.base_id && s.base_id == bomItem.child_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user