diff --git a/inventory-backend/app/api/v1/inbound/buy.py b/inventory-backend/app/api/v1/inbound/buy.py index e3caec3..7c6ea0c 100644 --- a/inventory-backend/app/api/v1/inbound/buy.py +++ b/inventory-backend/app/api/v1/inbound/buy.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify from app.services.inbound.buy_service import BuyInboundService from app.utils.decorators import permission_required +from app.utils.warehouse_location import location_error import traceback inbound_buy_bp = Blueprint('stock_buy', __name__) @@ -144,6 +145,12 @@ def submit(): 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 + new_stock = BuyInboundService.handle_inbound(data) # ★ Fail-Closed: 入库成功响应剥离价格字段 diff --git a/inventory-backend/app/api/v1/inbound/product.py b/inventory-backend/app/api/v1/inbound/product.py index d92a8a9..755e314 100644 --- a/inventory-backend/app/api/v1/inbound/product.py +++ b/inventory-backend/app/api/v1/inbound/product.py @@ -2,6 +2,7 @@ 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 @@ -117,6 +118,12 @@ def submit(): 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: diff --git a/inventory-backend/app/api/v1/inbound/semi.py b/inventory-backend/app/api/v1/inbound/semi.py index eeb1d87..1aaf6e5 100644 --- a/inventory-backend/app/api/v1/inbound/semi.py +++ b/inventory-backend/app/api/v1/inbound/semi.py @@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify from app.services.inbound.semi_service import SemiInboundService 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 @@ -121,6 +122,12 @@ def submit(): 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_semi:*' not in user_permissions: diff --git a/inventory-backend/app/utils/warehouse_location.py b/inventory-backend/app/utils/warehouse_location.py new file mode 100644 index 0000000..67c9163 --- /dev/null +++ b/inventory-backend/app/utils/warehouse_location.py @@ -0,0 +1,65 @@ +# app/utils/warehouse_location.py +""" +库位可用性校验 —— 回填历史库位前的把关。 + +库位带上公司归属之后,库存行里的历史库位字符串可能有两种失效: + · 指向一个**已被删除**的库位(改名/删除后残留的旧路径) + · 属于**另一家公司** + +两种都不该被静默回填进入库表单 —— 操作员会以为那是本公司的库位, +把货记到一个系统里根本不存在(或不该用)的地址上,而界面上看不出任何异常。 + +口径与 api/v1/warehouse.py 的 _visible_to 一致:本公司 或 全局共享(NULL)。 +""" +from sqlalchemy import or_ + +from app.extensions import db + + +def location_error(location): + """ + 入库写台账前的「库位必须存在」校验。合法返回 None,否则返回给用户的错误消息。 + + ★ 只管**存在**,不管归属公司 —— 跨公司使用库位不做限制(业务上可能共用)。 + 这一层挡的是历史那种裸数字脏值('1' / '5' / '7' / '14' / '22')。 + 它们当年能写进 stock_buy,只是因为旧校验仅拦了空串;本次摸底时实测到 5 条。 + + ★ 空值不归这里管:各接口自己的「库位必填」校验先跑,语义更清楚。 + """ + loc = (location or '').strip() + if not loc: + return None + if usable_locations(None, [loc]): + return None + return f'库位「{loc}」不存在,请从库位列表中选择' + + +def material_company(base_id): + """取物料所属公司。查不到返回 None —— 此时只做「库位是否存在」的校验。""" + from app.models.base import MaterialBase + row = db.session.query(MaterialBase.company_name).filter( + MaterialBase.id == base_id).first() + return (row[0] or None) if row else None + + +def usable_locations(company_name, locations): + """ + 从 locations 里筛出「仍存在、且该公司可见」的那些,保持传入顺序并去重。 + + company_name 为空时不做公司过滤,但**仍要求库位存在** —— + 「已被删除」这件事跟公司归属无关,任何情况下都不该回填一个不存在的库位。 + """ + wanted = [x for x in dict.fromkeys(locations) if x] + if not wanted: + return [] + + from app.models.system import SysWarehouseLocation + q = db.session.query(SysWarehouseLocation.full_path).filter( + SysWarehouseLocation.full_path.in_(wanted)) + if company_name: + q = q.filter(or_( + SysWarehouseLocation.company_name == company_name, + SysWarehouseLocation.company_name.is_(None), + )) + ok = {r[0] for r in q.all()} + return [x for x in wanted if x in ok]