Files
KCGL/inventory-backend/app/api/v1/inbound/semi.py

345 lines
15 KiB
Python
Raw Normal View History

# inventory-backend/app/api/v1/inbound/semi.py
2026-01-28 17:44:39 +08:00
from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService
from app.utils.decorators import permission_required, audit_log
from app.models.base import MaterialBase
from app.services.track_query_service import lookup_product
2026-01-28 17:44:39 +08:00
import traceback
# === 这一行非常关键,绝对不能丢!===
2026-02-06 10:16:37 +08:00
inbound_semi_bp = Blueprint('stock_semi', __name__)
2026-01-28 17:44:39 +08:00
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 []
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
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):
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
"""严格 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)
2026-01-28 17:44:39 +08:00
@inbound_semi_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_semi')
2026-01-28 17:44:39 +08:00
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})
2026-01-28 17:44:39 +08:00
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
2026-01-28 17:44:39 +08:00
@inbound_semi_bp.route('/list', methods=['GET'])
@permission_required('inbound_semi')
2026-01-28 17:44:39 +08:00
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')
2026-02-05 11:51:25 +08:00
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
2026-01-28 17:44:39 +08:00
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')
@audit_log(
module='半成品入库',
action='新增',
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
2026-01-28 17:44:39 +08:00
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:
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
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})
2026-01-28 17:44:39 +08:00
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
2026-01-28 17:44:39 +08:00
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_semi:operation')
@audit_log(
module='半成品入库',
action='修改',
get_target_id_fn=lambda: request.view_args.get('id'),
get_target_name_fn=lambda: request.get_json().get('material_name') if request.get_json() else None
)
2026-01-28 17:44:39 +08:00
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:
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
data.pop(field, None)
2026-01-28 17:44:39 +08:00
SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
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')
@audit_log(
module='半成品入库',
action='删除',
get_target_id_fn=lambda: request.view_args.get('id')
)
2026-01-28 17:44:39 +08:00
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})
2026-01-28 17:44:39 +08:00
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})
2026-03-02 13:39:52 +08:00
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}})