available_quantity 一旦为负,预占/释放/盘点的全部算术都会失真。此前有两处 缺口,本次一并堵上,使 available_quantity >= 0 成为不变量。 1) 执行阶段逐行扣减无下限校验(inventory_reservation.restore_then_deduct) 物料级校验只保证「Σ实扫 ≤ Σ可用」这一总量关系,拦不住「总量守恒但单行 穿仓」:同物料下 A 批可用 2、B 批可用 8,工人把 5 件全压在 A 批上,总量 5 ≤ 10 通过,A 批却被扣成 -3。借库走 deduct_stock=False,连实物数校验都 跳过,是裸扣。 已实测复现:借库与出库路径均可把单行扣成 -3。 校验放在 release_reserved() 之后,故不会误拒合法的换批次(物理覆盖): 释放后每行 available 已含本单预占,扫自己预占过的批次时 raw >= 0 保证 必然放行;改扫其它批次时,该批次实时可用量就是它自己的上限。 2) 下调入库数量可把可用数压成负(buy/product/semi 三处 update_inbound) 按 diff 同步增减 stock/available,但无任何下限检查。该批次若已有部分被 预占/出库/借出,向下调整即产生负可用数。现在下调前校验可用数是否够扣。 正常数据下 stock >= available 恒成立,故守住 available 同时守住 stock。 配套:三个 update_* 端点此前只捕获 Exception → 500,没有 ValueError 分支 (同文件的 delete_* 早就有)。补上 400 分支,使业务校验失败不再被记成 服务端故障。 验证(事务内执行并回滚,未落库):跨批次穿仓被拦、合法换批次放行、全额执行 本单预占批次放行、下调击穿被拦(API 返回 400,三个端点一致)、上调不受影响、 真实借库单 52 行全额执行正常、全库无负可用数。
332 lines
14 KiB
Python
332 lines
14 KiB
Python
# inventory-backend/app/api/v1/inbound/semi.py
|
||
from flask import Blueprint, request, jsonify
|
||
from app.services.inbound.semi_service import SemiInboundService
|
||
from app.utils.decorators import permission_required
|
||
from app.models.base import MaterialBase
|
||
from app.services.track_query_service import lookup_product
|
||
import traceback
|
||
|
||
# === 这一行非常关键,绝对不能丢!===
|
||
inbound_semi_bp = Blueprint('stock_semi', __name__)
|
||
|
||
def get_current_user_permissions():
|
||
from flask_jwt_extended import get_jwt
|
||
from app.services.auth_service import AuthService
|
||
claims = get_jwt()
|
||
user_role = claims.get('role')
|
||
user_company = claims.get('company_name', '')
|
||
if not user_role: return []
|
||
from app.utils.constants import UserRole
|
||
if str(user_role).strip().upper() == UserRole.SUPER_ADMIN: return ['inbound_semi:*']
|
||
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
|
||
return perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||
|
||
def filter_item_by_permissions(item_dict, user_permissions):
|
||
"""严格 Default Deny 字段过滤 (see app/utils/field_permissions.py)"""
|
||
from app.utils.field_permissions import apply_strict_rbac
|
||
return apply_strict_rbac(item_dict, 'StockSemi', user_permissions)
|
||
|
||
@inbound_semi_bp.route('/search-base', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def search_base():
|
||
try:
|
||
keyword = request.args.get('keyword', '')
|
||
page = request.args.get('page', 1, type=int)
|
||
result = SemiInboundService.search_base_material(keyword, page)
|
||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
@inbound_semi_bp.route('/search-bom', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def search_bom():
|
||
try:
|
||
keyword = request.args.get('keyword', '')
|
||
parent_spec = request.args.get('parent_spec', None)
|
||
data = SemiInboundService.search_bom_options(keyword, parent_spec=parent_spec)
|
||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
@inbound_semi_bp.route('/list', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def get_list():
|
||
try:
|
||
page = request.args.get('page', 1, type=int)
|
||
limit = request.args.get('pageSize', 15, type=int)
|
||
keyword = request.args.get('keyword', '')
|
||
sku = request.args.get('sku', '')
|
||
search_field = request.args.get('searchField', 'all')
|
||
statuses_str = request.args.get('statuses', '')
|
||
statuses = statuses_str.split(',') if statuses_str else []
|
||
company = request.args.get('company', '')
|
||
category = request.args.get('category', '')
|
||
material_type = request.args.get('material_type', '')
|
||
order_by_column = request.args.get('orderByColumn', '')
|
||
is_asc = request.args.get('isAsc', '')
|
||
advanced_filters_str = request.args.get('advancedFilters', '')
|
||
|
||
# 准备额外筛选字典
|
||
extra_filters = {}
|
||
if company:
|
||
extra_filters['company'] = company
|
||
if category:
|
||
extra_filters['category'] = category
|
||
if material_type:
|
||
extra_filters['material_type'] = material_type
|
||
if order_by_column:
|
||
extra_filters['order_by_column'] = order_by_column
|
||
if is_asc:
|
||
extra_filters['is_asc'] = is_asc
|
||
if advanced_filters_str:
|
||
try:
|
||
import json
|
||
advanced_filters = json.loads(advanced_filters_str)
|
||
extra_filters['advanced_filters'] = advanced_filters
|
||
except Exception:
|
||
extra_filters['advanced_filters'] = []
|
||
|
||
# 调用服务,传入所有参数
|
||
result = SemiInboundService.get_list(
|
||
page, limit, keyword, sku, search_field, statuses,
|
||
**extra_filters
|
||
)
|
||
user_permissions = get_current_user_permissions()
|
||
if result.get('items'):
|
||
# ★ 字段权限过滤会剥离 base_id,但前端扫码/选物料联动需要它匹配历史批号
|
||
# 在过滤前保留 base_id,过滤后补回(非敏感字段,仅物料关联 ID)
|
||
preserved_items = []
|
||
for item in result['items']:
|
||
base_id = item.get('base_id')
|
||
filtered = filter_item_by_permissions(item, user_permissions)
|
||
if base_id is not None:
|
||
filtered['base_id'] = base_id
|
||
preserved_items.append(filtered)
|
||
result['items'] = preserved_items
|
||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
@inbound_semi_bp.route('/submit', methods=['POST'])
|
||
@permission_required('inbound_semi:operation')
|
||
def submit():
|
||
try:
|
||
data = request.get_json()
|
||
if not data: return jsonify({"code": 400, "msg": "No data"}), 400
|
||
|
||
# 库位必填校验(安全兜底)
|
||
location = data.get('warehouse_location', '').strip()
|
||
if not location:
|
||
return jsonify({"code": 400, "msg": "入库失败:库位为必填项,不能为空!"}), 400
|
||
|
||
user_permissions = get_current_user_permissions()
|
||
if 'inbound_semi:*' not in user_permissions:
|
||
for field in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'total_price'):
|
||
perm_code = f'inbound_semi:{field}'
|
||
if field in data and perm_code not in user_permissions:
|
||
data.pop(field, None)
|
||
new_stock = SemiInboundService.handle_inbound(data)
|
||
from app.utils.field_permissions import apply_strict_rbac
|
||
resp = apply_strict_rbac(new_stock.to_dict(), 'StockSemi', user_permissions)
|
||
return jsonify({"code": 200, "msg": "入库成功", "data": resp})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
@inbound_semi_bp.route('/track-lookup', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def track_lookup():
|
||
"""扫码入库:根据 Track 身份证查询产品信息,并匹配 MOM 物料库"""
|
||
try:
|
||
code = (request.args.get('code') or '').strip()
|
||
if not code:
|
||
return jsonify({"code": 400, "msg": "code 参数不能为空"}), 400
|
||
|
||
track_info = lookup_product(code)
|
||
if not track_info:
|
||
return jsonify({"code": 404, "msg": "未在 Track 系统中找到该身份证对应的产品"}), 404
|
||
|
||
material_id = track_info.get('material_id')
|
||
material = None
|
||
if material_id:
|
||
try:
|
||
material_id = int(material_id)
|
||
except (TypeError, ValueError):
|
||
material_id = None
|
||
if material_id:
|
||
material = MaterialBase.query.filter(
|
||
MaterialBase.id == material_id,
|
||
MaterialBase.is_enabled == True
|
||
).first()
|
||
if material is None:
|
||
return jsonify({
|
||
"code": 404,
|
||
"msg": "扫码产品的物料在 MOM 物料库中不存在,请先在【基础信息】中完善该物料后再入库"
|
||
}), 404
|
||
|
||
return jsonify({
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": {
|
||
"track": track_info,
|
||
"material": {
|
||
"id": material.id,
|
||
"company_name": material.company_name,
|
||
"name": material.name,
|
||
"spec": material.spec_model,
|
||
"category": material.category,
|
||
"unit": material.unit,
|
||
"type": material.material_type,
|
||
}
|
||
}
|
||
})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
|
||
@permission_required('inbound_semi:operation')
|
||
def update_semi(id):
|
||
try:
|
||
data = request.get_json()
|
||
user_permissions = get_current_user_permissions()
|
||
if 'inbound_semi:*' not in user_permissions:
|
||
field_to_perm = {
|
||
'id': 'inbound_semi:id',
|
||
'base_id': 'inbound_semi:base_id',
|
||
'company_name': 'material_list:companyName',
|
||
'material_name': 'material_list:name',
|
||
'spec_model': 'material_list:spec',
|
||
'category': 'material_list:category',
|
||
'material_type': 'material_list:type',
|
||
'unit': 'material_list:unit',
|
||
'sku': 'inbound_semi:sku',
|
||
'in_date': 'inbound_semi:inbound_date',
|
||
'barcode': 'inbound_semi:barcode',
|
||
'serial_number': 'inbound_semi:sn_bn',
|
||
'batch_number': 'inbound_semi:sn_bn',
|
||
'warehouse_location': 'inbound_semi:warehouse_loc',
|
||
'status': 'inbound_semi:status',
|
||
'quality_status': 'inbound_semi:quality_status',
|
||
'in_quantity': 'inbound_semi:in_quantity',
|
||
'stock_quantity': 'inbound_semi:stock_quantity',
|
||
'available_quantity': 'inbound_semi:available_quantity',
|
||
'bom_code': 'inbound_semi:bom_code',
|
||
'bom_version': 'inbound_semi:bom_version',
|
||
'work_order_code': 'inbound_semi:work_order_code',
|
||
'raw_material_cost': 'inbound_semi:raw_material_cost',
|
||
'manual_cost': 'inbound_semi:manual_cost',
|
||
'unit_total_cost': 'inbound_semi:unit_total_cost',
|
||
'total_price': 'inbound_semi:total_price',
|
||
'production_manager': 'inbound_semi:production_manager',
|
||
'production_start_time': 'inbound_semi:production_start_time',
|
||
'production_end_time': 'inbound_semi:production_end_time',
|
||
'arrival_photo': 'inbound_semi:arrival_photo',
|
||
'quality_report_link': 'inbound_semi:quality_report_link',
|
||
'detail_link': 'inbound_semi:detail_link',
|
||
'remark': 'inbound_semi:remark',
|
||
'print_copies': 'inbound_semi:print_copies',
|
||
}
|
||
for field in list(data.keys()):
|
||
perm_code = field_to_perm.get(field)
|
||
if perm_code is None:
|
||
data.pop(field, None) # Default Deny
|
||
elif perm_code not in user_permissions:
|
||
data.pop(field, None)
|
||
SemiInboundService.update_inbound(id, data)
|
||
return jsonify({"code": 200, "msg": "更新成功"})
|
||
except ValueError as ve:
|
||
# 业务校验失败(如下调数量会击穿可用库存)→ 400,非服务端故障
|
||
return jsonify({"code": 400, "msg": str(ve)}), 400
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
@inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
|
||
@permission_required('inbound_semi:operation')
|
||
def delete_semi(id):
|
||
try:
|
||
material_name = SemiInboundService.delete_inbound(id)
|
||
return jsonify({"code": 200, "msg": "删除成功", "material_name": material_name})
|
||
except ValueError as ve:
|
||
return jsonify({"code": 400, "msg": str(ve)})
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": f"服务器内部错误详情: {str(e)}"}), 500
|
||
|
||
@inbound_semi_bp.route('/<int:id>/history', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def get_history(id):
|
||
try:
|
||
data = SemiInboundService.get_outbound_history(id)
|
||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
@inbound_semi_bp.route('/suggestions/users', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def get_user_suggestions():
|
||
keyword = request.args.get('keyword', '')
|
||
data = SemiInboundService.search_system_users(keyword)
|
||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||
|
||
@inbound_semi_bp.route('/options', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def get_options():
|
||
try:
|
||
data = SemiInboundService.get_filter_options()
|
||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||
except Exception as e:
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
@inbound_semi_bp.route('/suggestions/managers', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def get_manager_history():
|
||
keyword = request.args.get('keyword', '')
|
||
try:
|
||
data = SemiInboundService.get_history_managers(keyword)
|
||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
# ------------------------------------------------------------------
|
||
# 9. BOM 原材料成本自动核算 (新增)
|
||
# ------------------------------------------------------------------
|
||
@inbound_semi_bp.route('/calculate-bom-cost', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def calculate_bom_cost():
|
||
try:
|
||
bom_code = request.args.get('bom_code')
|
||
bom_version = request.args.get('bom_version')
|
||
if not bom_code or not bom_version:
|
||
return jsonify({"code": 400, "msg": "bom_code和bom_version不能为空"}), 400
|
||
cost = SemiInboundService.calculate_bom_cost(bom_code, bom_version)
|
||
return jsonify({"code": 200, "msg": "success", "data": cost})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 获取最近一次入库的库位(跨表查询)
|
||
# ------------------------------------------------------------------
|
||
@inbound_semi_bp.route('/last-location', methods=['GET'])
|
||
@permission_required('inbound_semi')
|
||
def get_last_location():
|
||
"""
|
||
获取指定物料最近一次入库的库位
|
||
查询顺序:半成品入库 -> 采购入库 -> 成品入库,返回最新入库的库位
|
||
"""
|
||
base_id = request.args.get('base_id', type=int)
|
||
if not base_id:
|
||
return jsonify({"code": 400, "msg": "base_id required"}), 400
|
||
|
||
location = SemiInboundService.get_last_location_by_base_id(base_id)
|
||
return jsonify({"code": 200, "msg": "success", "data": {"location": location}})
|