diff --git a/inventory-backend/app/api/v1/purchase.py b/inventory-backend/app/api/v1/purchase.py index a1b7146..b49e94f 100644 --- a/inventory-backend/app/api/v1/purchase.py +++ b/inventory-backend/app/api/v1/purchase.py @@ -423,3 +423,37 @@ def search_material_for_purchase(): except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': str(e)}), 500 + + +# -------------------------------------------------------- +# 9. 待采购池(防重复采购) +# GET /api/v1/purchase/pending-pool?page=1&limit=20&keyword=xxx +# -------------------------------------------------------- +@purchase_bp.route('/pending-pool', methods=['GET']) +@jwt_required() +@permission_required('inbound_purchase:pending_pool') +def get_pending_purchase_pool(): + """ + 待采购清单:库存已触及预警线、且没有活跃采购单的物料。 + + 防重逻辑的核心 —— 物料一旦有在途采购单(待审批/已通过/部分到货未齐), + 立即从本列表中消失;单据被驳回或强制结案后才会重新回流。 + """ + try: + page = request.args.get('page', 1, type=int) + limit = request.args.get('limit', 20, type=int) + keyword = request.args.get('keyword', '').strip() or None + category = request.args.get('category', '').strip() or None + material_type = request.args.get('type', '').strip() or None + + warning_status = request.args.get('warning_status', type=int) + + result = PurchaseService.get_pending_purchase_pool( + page=page, per_page=limit, keyword=keyword, + category=category, material_type=material_type, + warning_status=warning_status, + ) + return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500 diff --git a/inventory-backend/app/services/permission_service.py b/inventory-backend/app/services/permission_service.py index 9024154..0b8fb97 100644 --- a/inventory-backend/app/services/permission_service.py +++ b/inventory-backend/app/services/permission_service.py @@ -673,6 +673,12 @@ class PermissionService: ('inbound_purchase:unit_price', '采购单价', 'column'), ('inbound_purchase:total_price', '采购总价', 'column'), ('inbound_purchase:tax_rate', '税率', 'column'), + # ★ 待采购清单(防重复采购)菜单权限。 + # 注册成 element 而非新建 SysMenu:侧边栏与路由守卫只读前端 + # meta.permissions + sys_role_permission,从不读 sys_menu; + # 且 init_all_menus 的「冗余子菜单清理」会按 target_code 删权限行, + # 新增 SysMenu 等于多一个被该清理逻辑波及的面。 + ('inbound_purchase:pending_pool', '待采购清单', 'operation'), ] for code, name, etype in purchase_elements: existing = SysElement.query.filter_by( @@ -833,6 +839,48 @@ class PermissionService: print(f"[权限迁移] {role_code}: inbound_buy → inbound_purchase 已自动迁移") total_inserted += 1 + # ★ 自动迁移:已有「采购申请」菜单权限的角色 → 自动获得「待采购清单」权限 + # + # 为什么必须独立成段:上面的默认分配逻辑在角色**已有任何权限**时 + # 就整段跳过(见上方 existing_count > 0 的分支),存量角色根本走不到 + # 那里。不写这一段,除了超管之外没有任何角色能拿到新权限。 + # + # 本段依赖 autoflush:上一个循环刚 add 的 inbound_purchase 行尚未 + # commit,这里的查询必须能看见它们(扩展默认 autoflush=True 保证了这点)。 + for role_code in known_roles: + if role_code.upper() == 'SUPER_ADMIN': + continue + + # 以「采购申请菜单权限」为门控 —— 能进采购页的角色就该看到待采购清单。 + # ★ 同时镜像它的 company_name:若某角色的采购菜单是公司定制的 + # (company_name='A'),补出的权限必须落在同一作用域;硬写 NULL + # 会让其他公司的同角色用户凭空多出这个菜单。 + src_perm = SysRolePermission.query.filter_by( + role_code=role_code, target_code='inbound_purchase', type='menu' + ).first() + if not src_perm: + continue + + # 幂等检查也必须带 company_name,否则 A 公司已有行会跳过 B 公司那条 + already = SysRolePermission.query.filter_by( + role_code=role_code, + target_code='inbound_purchase:pending_pool', + type='element', + company_name=src_perm.company_name, + ).first() + if already: + continue + + db.session.add(SysRolePermission( + role_code=role_code, + target_code='inbound_purchase:pending_pool', + type='element', + company_name=src_perm.company_name, + )) + total_inserted += 1 + print(f"[权限迁移] {role_code}: 已补充「待采购清单」权限" + f"(作用域={src_perm.company_name or '全局'})") + # ★ SUPERVISOR 主管默认获得采购申请子菜单权限 for role_code in known_roles: if role_code.upper() == 'SUPERVISOR': diff --git a/inventory-backend/app/services/purchase_service.py b/inventory-backend/app/services/purchase_service.py index 8cf8be7..471a49e 100644 --- a/inventory-backend/app/services/purchase_service.py +++ b/inventory-backend/app/services/purchase_service.py @@ -528,4 +528,248 @@ class PurchaseService: """ send_email_async(requester.email, subject, content) except Exception as e: - print(f"[Email] 采购申请驳回通知失败: {e}") \ No newline at end of file + print(f"[Email] 采购申请驳回通知失败: {e}") + + # ============================================================ + # 待采购池(防重复采购) + # ============================================================ + @staticmethod + def get_pending_purchase_pool(page=1, per_page=20, keyword=None, + category=None, material_type=None, + warning_status=None): + """ + 待采购池:找出「**有效供给**仍不足、需要再买」的物料。 + + 业务目的:杜绝多名采购员对同一短缺物料重复购买,同时**不能漏掉缺口**。 + + 核心是有效供给: + + 有效供给 = 当前物理总库存 + 在途量 + 在途量 = SUM(该物料所有活跃采购单的剩余待入库量) + = SUM(GREATEST(采购量 - 累计入库量, 0)) + + 过滤规则:有效供给 <= 红/黄阈值 才留在池中。 + + ★ 为什么不是「有采购单就排除」(本接口的第一版): + 红线 10、库存 0、某采购员只建了一张数量 5 的单 —— 若按 EXISTS 一刀切, + 该物料会**立刻从池中消失**,剩下 5 个缺口永远无人认领。这是掩耳盗铃。 + 按量计算后它继续留在池中,suggested_qty 自动降到 5,提醒下一位只补 5 个。 + + ★ 判定口径必须与物料列表预警(inbound/base_service.get_list)严格一致, + 否则会出现「列表亮红灯、池子却说不用买」的撕裂: + - 用**物理库存总量**(三个 stock_* 表的 stock_quantity 之和),不是可用量 + - 阈值可能为 None,红黄两个阈值**各自独立**判断,不能 coalesce 成一个 + - 用 `<=` 而非 `<` —— 恰好等于阈值即算不足 + + ★ 关于 `<=`(2026-09-18 与业务方确认,**刻意维持,勿擅自改成 `<`**): + 阈值是「警戒下限」,到线即需关注;且物料列表预警、预警邮件用的是同一套 + `<=` 口径(那是系统原有约定,不是本接口另立的)。若只把本接口改成 `<`, + 会出现「列表亮黄灯、池子却把它排除」的撕裂。 + 代价是有效供给恰好等于阈值时 suggested_qty 会落到最小值 1(看似 + 「不用买却建议买 1 个」)—— 这是有意的缓冲,前端 tooltip 已单独说明。 + 真要改成 `<`,必须**同时**改 base_service 的判级与 inventory_task 的 + 触发条件,三处一起动。 + """ + import math + + from sqlalchemy import and_, case, cast, desc, or_ + from sqlalchemy.types import Numeric as SqlNumeric + + from app.models.base import MaterialWarningSetting + from app.models.inbound.buy import StockBuy + from app.models.inbound.product import StockProduct + from app.models.inbound.semi import StockSemi + from app.utils.decorators import get_current_company_filter + from app.utils.purchase_activity import in_transit_subquery + + # ---- 三表库存聚合(口径同 base_service.get_list:146-170)---- + buy_sub = db.session.query( + StockBuy.base_id, + func.sum(StockBuy.stock_quantity).label('buy_inv'), + func.sum(StockBuy.available_quantity).label('buy_avail') + ).group_by(StockBuy.base_id).subquery() + + semi_sub = db.session.query( + StockSemi.base_id, + func.sum(StockSemi.stock_quantity).label('semi_inv'), + func.sum(StockSemi.available_quantity).label('semi_avail') + ).group_by(StockSemi.base_id).subquery() + + prod_sub = db.session.query( + StockProduct.base_id, + func.sum(StockProduct.stock_quantity).label('prod_inv'), + func.sum(StockProduct.available_quantity).label('prod_avail') + ).group_by(StockProduct.base_id).subquery() + + total_inv = (func.coalesce(buy_sub.c.buy_inv, 0) + + func.coalesce(semi_sub.c.semi_inv, 0) + + func.coalesce(prod_sub.c.prod_inv, 0)) + total_avail = (func.coalesce(buy_sub.c.buy_avail, 0) + + func.coalesce(semi_sub.c.semi_avail, 0) + + func.coalesce(prod_sub.c.prod_avail, 0)) + + # ★ 必须先物化再过滤:total_inv 是聚合表达式,直接在带 GROUP BY 的查询里 + # 参与比较会落到 HAVING 语义;物化成子查询后,它在外层就是普通列, + # 可以安全地进 WHERE(与 base_service.get_list:175-191 同一手法)。 + inner_sub = ( + db.session.query( + MaterialBase.id.label('base_id'), + total_inv.label('total_inv'), + total_avail.label('total_avail'), + ) + .outerjoin(buy_sub, MaterialBase.id == buy_sub.c.base_id) + .outerjoin(semi_sub, MaterialBase.id == semi_sub.c.base_id) + .outerjoin(prod_sub, MaterialBase.id == prod_sub.c.base_id) + .subquery() + ) + + # ---- 在途量聚合(每个物料一张单一张单地累加剩余待入库量)---- + intransit_sub = in_transit_subquery() + + inv_col = inner_sub.c.total_inv + avail_col = inner_sub.c.total_avail + # 没有活跃采购单的物料不在 intransit_sub 里,outerjoin 后为 NULL → 兜成 0 + intransit_col = func.coalesce(intransit_sub.c.intransit, 0) + # ★ 有效供给:判缺货、算建议量,全部基于它,而不是光看库存 + effective_col = inv_col + intransit_col + red_col = cast(MaterialWarningSetting.red_threshold, SqlNumeric) + yellow_col = cast(MaterialWarningSetting.yellow_threshold, SqlNumeric) + + # inner join 预警配置:缺货的前提就是预警已启用,outer join 无意义。 + # (material_warning_settings 按约定与物料 1:1 且已核实无重复行, + # 故不会因 join 放大行数。若将来出现重复行,此处需要改为每物料取一条。) + query = ( + db.session.query(MaterialBase, MaterialWarningSetting, + inv_col, avail_col, intransit_col) + .join(inner_sub, MaterialBase.id == inner_sub.c.base_id) + .outerjoin(intransit_sub, MaterialBase.id == intransit_sub.c.base_id) + .join(MaterialWarningSetting, MaterialBase.id == MaterialWarningSetting.base_id) + .filter(MaterialWarningSetting.is_enabled.is_(True)) + ) + + # ---- ① 有效供给不足 ---- + # 对应 Python 侧的 `if 供给<=红 ... elif 供给<=黄`:命中任一即不足。 + # ★ 绝不写成 `supply <= coalesce(red, yellow)` —— 那会把「红阈值未配置」 + # 偷换成「用黄阈值」,且在两者都为 NULL 时静默退化成 NULL 比较。 + red_hit = and_(MaterialWarningSetting.red_threshold.isnot(None), effective_col <= red_col) + yellow_hit = and_(MaterialWarningSetting.yellow_threshold.isnot(None), effective_col <= yellow_col) + query = query.filter(or_(red_hit, yellow_hit)) + + # ---- 行级数据隔离(多租户)---- + company_limit = get_current_company_filter() + if company_limit is not None: + query = query.filter(MaterialBase.company_name == company_limit) + + # ---- 可选筛选 ---- + if keyword: + kw = f'%{keyword.strip()}%' + query = query.filter(or_( + MaterialBase.name.ilike(kw), + MaterialBase.spec_model.ilike(kw), + MaterialBase.company_name.ilike(kw), + )) + + if category: + query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%")) + + if material_type: + query = query.filter(MaterialBase.material_type.ilike(material_type.strip())) + + if warning_status == 2: + query = query.filter(red_hit) + elif warning_status == 1: + # 黄色要在红之外,否则 1/2 语义重叠 + query = query.filter(yellow_hit, ~red_hit) + + # ---- 排序:最缺的排最上面(复刻 base_service.get_list:372-391 的口径)---- + warning_level = case( + (red_hit, 2), + (yellow_hit, 1), + else_=0, + ) + # 缺口按**有效供给**算,在途已经补上的部分不再计入缺口 + gap = case( + (red_hit, red_col - effective_col), + (yellow_hit, yellow_col - effective_col), + else_=0, + ) + query = query.order_by(desc(warning_level), desc(gap), MaterialBase.id.asc()) + + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + + items = [] + for row in pagination.items: + material = row[0] + setting = row[1] + inv = float(row[2]) if row[2] is not None else 0.0 + avail = float(row[3]) if row[3] is not None else 0.0 + intransit = float(row[4]) if row[4] is not None else 0.0 + effective = inv + intransit + + red = float(setting.red_threshold) if setting.red_threshold is not None else None + yellow = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None + + # 判级用有效供给 —— 与上面的 SQL 过滤条件保持同一口径 + status = 0 + if red is not None and effective <= red: + status = 2 + elif yellow is not None and effective <= yellow: + status = 1 + + # 建议采购量 = 阈值 - 有效供给,一次性把供给抬出**整个**预警带, + # 否则刚补完货下一条预警马上又来。 + # 取 max(红,黄) 而非「被触发的那个」——正常配置下红<黄,补到黄线即可 + # 完全退出;红黄配反的脏数据下也安全。 + # 双阈值都为 None 时降级为 None,不会出现 None - effective 的 TypeError。 + # + # ★ 注意在途已计入 effective,所以「已买 5」时这里只会建议剩下的 5, + # 而不是按库存从零算。这正是本次改造要修的那个漏洞。 + targets = [t for t in (red, yellow) if t is not None] + target = max(targets) if targets else None + suggested = max(1, math.ceil(target - effective)) if target is not None else None + + items.append({ + 'material_id': material.id, + 'name': material.name, + 'spec_model': material.spec_model or '', + 'unit': material.unit or '', + 'category': material.category or '', + 'material_type': material.material_type or '', + 'company_name': material.company_name or '', + 'image': PurchaseService._first_image(material.product_image), + 'purchase_link': material.purchase_link or '', + 'reference_price': float(material.reference_price) if material.reference_price is not None else None, + 'inventory_count': inv, + 'available_count': avail, + # 在途量与有效供给一并返回,前端才能向采购员解释「为什么只建议买这么多」 + 'in_transit_qty': intransit, + 'effective_supply': effective, + 'warning_status': status, + 'warning_red': red, + 'warning_yellow': yellow, + 'target_threshold': target, + 'suggested_qty': suggested, + 'is_ordered': bool(setting.is_ordered), + }) + + return { + 'items': items, + 'total': pagination.total, + 'pages': pagination.pages, + 'current_page': page, + } + + @staticmethod + def _first_image(product_image): + """从 material_base.product_image 的 JSON 字符串里取第一张图,取不到返回空串。""" + if not product_image: + return '' + try: + if isinstance(product_image, str) and not product_image.startswith('['): + return product_image # 兼容旧数据:单条 URL 直接存字符串 + parsed = json.loads(product_image) + if isinstance(parsed, list) and parsed: + return parsed[0] or '' + except Exception: + return '' + return '' \ No newline at end of file