Files
KCGL/inventory-backend/app/api/v1/inbound/product.py
yueli a8a3c82331 fix(stock): 补行级防穿仓与入库改量下限,杜绝可用数变负
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 行全额执行正常、全库无负可用数。
2026-09-11 10:34:15 +08:00

321 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# inventory-backend/app/api/v1/inbound/product.py
from flask import Blueprint, request, jsonify
from app.services.inbound.product_service import ProductInboundService
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_product_bp = Blueprint('stock_product', __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_product:*']
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, 'StockProduct', user_permissions)
@inbound_product_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_product')
def search_base():
try:
keyword = request.args.get('keyword', '')
page = request.args.get('page', 1, type=int)
result = ProductInboundService.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_product_bp.route('/search-bom', methods=['GET'])
@permission_required('inbound_product')
def search_bom():
try:
keyword = request.args.get('keyword', '')
parent_spec = request.args.get('parent_spec', None)
data = ProductInboundService.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_product_bp.route('/list', methods=['GET'])
@permission_required('inbound_product')
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 []
category = request.args.get('category', '')
material_type = request.args.get('material_type', '')
company = request.args.get('company', '')
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 = ProductInboundService.get_list(
page, limit, keyword, sku, search_field, statuses,
category=extra_filters.get('category'),
material_type=extra_filters.get('material_type'),
company=extra_filters.get('company'),
order_by_column=extra_filters.get('order_by_column'),
is_asc=extra_filters.get('is_asc'),
advanced_filters=extra_filters.get('advanced_filters')
)
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['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_product_bp.route('/submit', methods=['POST'])
@permission_required('inbound_product: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_product:*' not in user_permissions:
# ★ 基本操作字段(库位/数量等)不参与字段权限过滤
field_to_perm = {'id': 'inbound_product:id', 'company_name': 'inbound_product:company_name', 'material_name': 'inbound_product:material_name', 'category': 'inbound_product:category', 'material_type': 'inbound_product:material_type', 'spec_model': 'inbound_product:spec_model', 'unit': 'inbound_product:unit', 'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date', 'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number', 'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status', 'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version', 'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id', 'production_manager': 'inbound_product:production_manager', 'production_start_time': 'inbound_product:production_start_time', 'production_end_time': 'inbound_product:production_end_time', 'raw_material_cost': 'inbound_product:raw_material_cost', 'manual_cost': 'inbound_product:manual_cost', 'sale_price': 'inbound_product:sale_price', 'product_photo': 'inbound_product:product_photo', 'quality_report_link': 'inbound_product:quality_report_link', 'inspection_report_link': 'inbound_product:inspection_report_link', 'detail_link': 'inbound_product:detail_link'}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
new_stock = ProductInboundService.handle_inbound(data)
from app.utils.field_permissions import apply_strict_rbac
resp = apply_strict_rbac(new_stock.to_dict(), 'StockProduct', 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_product_bp.route('/track-lookup', methods=['GET'])
@permission_required('inbound_product')
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_product_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_product:operation')
def update(id):
try:
data = request.get_json()
user_permissions = get_current_user_permissions()
if 'inbound_product:*' not in user_permissions:
field_to_perm = {
'id': 'inbound_product:id', 'base_id': 'inbound_product:base_id',
'company_name': 'material_list:companyName', 'material_name': 'material_list:name',
'category': 'material_list:category', 'material_type': 'material_list:type',
'spec_model': 'material_list:spec', 'unit': 'material_list:unit',
'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date',
'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number',
'warehouse_location': 'inbound_product:warehouse_loc',
'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status',
'in_quantity': 'inbound_product:in_quantity', 'stock_quantity': 'inbound_product:stock_quantity',
'available_quantity': 'inbound_product:available_quantity',
'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version',
'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id',
'production_manager': 'inbound_product:production_manager',
'production_start_time': 'inbound_product:production_start_time',
'production_end_time': 'inbound_product:production_end_time',
'raw_material_cost': 'inbound_product:raw_material_cost',
'manual_cost': 'inbound_product:manual_cost',
'unit_total_cost': 'inbound_product:unit_total_cost',
'total_price': 'inbound_product:total_price',
'sale_price': 'inbound_product:sale_price',
'product_photo': 'inbound_product:product_photo',
'quality_report_link': 'inbound_product:quality_report_link',
'inspection_report_link': 'inbound_product:inspection_report_link',
'detail_link': 'inbound_product:detail_link',
'remark': 'inbound_product:remark',
'print_copies': 'inbound_product: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)
ProductInboundService.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_product_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_product:operation')
def delete(id):
try:
material_name = ProductInboundService.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_product_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_product')
def get_history(id):
try:
data = ProductInboundService.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_product_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_product')
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = ProductInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
@inbound_product_bp.route('/options', methods=['GET'])
@permission_required('inbound_product')
def get_options():
try:
data = ProductInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_product_bp.route('/suggestions/managers', methods=['GET'])
@permission_required('inbound_product')
def get_manager_history():
keyword = request.args.get('keyword', '')
try:
data = ProductInboundService.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_product_bp.route('/calculate-bom-cost', methods=['GET'])
@permission_required('inbound_product')
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 = ProductInboundService.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_product_bp.route('/last-location', methods=['GET'])
@permission_required('inbound_product')
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 = ProductInboundService.get_last_location_by_base_id(base_id)
return jsonify({"code": 200, "msg": "success", "data": {"location": location}})