Files
KCGL/inventory-backend/app/api/v1/inbound/product.py
yueli 3e131fa584 feat(inbound): 入库提交时校验库位必须存在
此前入库只校验库位「非空字符串」,任意值都能写进 stock_buy.warehouse_location —
存量数据里那 5 条裸数字('1' '5' '7' '14' '22')就是这么来的。

新增 app/utils/warehouse_location.py:
- location_error(location)      —— 库位必须存在于 sys_warehouse_location
- usable_locations(company, xs) —— 批量筛出「存在且本公司可见」的库位
- material_company(base_id)     —— 取物料所属公司

★ 只校验存在,**不校验归属公司** —— 跨公司使用库位不做限制(业务上可能共用)。
  若日后要收紧,location_error 里传一次 material_company(base_id) 即可。

空值仍由各接口原有的「必填」校验负责,两件事语义分开、互不干扰。
三个接口(buy / product / semi 的 /submit)各加一行调用。
2026-09-29 13:33:35 +08:00

328 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.utils.warehouse_location import location_error
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
# 库位存在性校验:必须在 sys_warehouse_location 里查得到。
# ★ 只校验存在,**不校验归属公司** —— 跨公司使用库位不做限制。
err = location_error(location)
if err:
return jsonify({"code": 400, "msg": f"入库失败:{err}"}), 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}})