""" 「活跃采购单」的判定与在途量计算 —— 防重复采购的唯一真相源。 本模块回答**两个不同的问题**,别混用: active_purchase_exists() 有没有人在买? → 展示用(在途标识、邮件静音) in_transit_subquery() 已经买了多少还没到? → 算术用(待采购池的有效供给) 「有在途单」不等于「不会缺货」:红线上线 10、库存 0、已下单 5,仍然差 5。 早期待采购池用 EXISTS 一刀切排除有单的物料,结果是只买了 5 个就把整个物料 从池子里抹掉、剩下的缺口永远无人认领(掩耳盗铃)。现在改为按量计算。 判定的业务含义(两张表共用同一份 _active_condition): status 0(待审批) / 1(已通过未入库) → 算数 status 3(已完成) 但累计入库 < 采购量 → 算数(部分到货,缺口仍在) status 2(已驳回) → 不算数,物料正常回流 status 4(已完结 / 强制结案) → 不算数,立即解锁 ★ status==4 的解锁能力是刻意保留的逃生通道:供应商短交不补发、来料不良拒收 不补发等异常单永远达不到采购量,若按「累计入库 < 采购量」一律算数,该物料 会被永久占用、再也无法触发采购。库管把单据强制结案即可解除。 「部分到货」为什么能在不改表结构的前提下算出来: StockBuy.request_id 早已存在(打通「按单入库」链路时加的),且 StockBuy.in_quantity 是入库原始量 —— 它不会被出库扣减(区别于 stock_quantity),按 request_id 聚合即得每张单的累计到货量。 """ from sqlalchemy import and_, func, or_ from app.extensions import db def received_quantity_subquery(): """ 每张采购单的累计到货量:{request_id: SUM(in_quantity)}。 只统计挂在该采购单下的入库行(request_id 非空)——散单入库不计入任何单据。 """ # 惰性导入:app/models/inbound/buy.py 已在模块顶层 import 了 # app/models/purchase.py,若此处顶层反向导入会形成循环。 from app.models.inbound.buy import StockBuy return ( db.session.query( StockBuy.request_id.label('rid'), func.sum(StockBuy.in_quantity).label('received'), ) .filter(StockBuy.request_id.isnot(None)) .group_by(StockBuy.request_id) .subquery() ) def _active_condition(received_sub): """ 「这张采购单还算数吗」的判定条件 —— active_purchase_exists 与 in_transit_subquery 共用,保证两处口径永不漂移。 status 0(待审批) / 1(已通过未入库) → 算数 status 3(已完成) 但累计入库 < 采购量 → 算数(部分到货,缺口仍在) status 2(已驳回) / 4(已完结) → 不算数 """ from app.models.purchase import PurchaseRequest return or_( # 待审批 + 已通过未入库 PurchaseRequest.status.in_([0, 1]), # 已入库但没到齐 —— 只到一部分时仍算数,缺口要留在账上 and_( PurchaseRequest.status == 3, func.coalesce(received_sub.c.received, 0) < PurchaseRequest.quantity, ), ) def active_purchase_exists(base_id_col): """ 构造「该物料存在活跃采购单」的 EXISTS 表达式。 ★ 这是**展示用**的布尔判定(物料列表的「采购在途」标识、预警邮件静音), 回答的是「有没有人在买」。它**不**回答「还差多少」—— 待采购池的过滤不能用它,那个要用 in_transit_subquery() 算有效供给。 Args: base_id_col: 待关联的物料主键列,通常是 MaterialBase.id。 Returns: SQLAlchemy EXISTS 表达式,可直接用于 .filter(~expr), 或作为 SELECT 列表里的布尔列。 ★ 为什么必须用 EXISTS 而不是 NOT IN: NOT IN 的子查询若返回 NULL,整个谓词会变成 NULL,调用方会**静默地** 拿到零行。EXISTS 天然 NULL 安全。 ★ 为什么显式 .correlate(...): 调用方的外层 FROM 里除了 material_base 往往还有别的表 (MaterialWarningSetting、物化后的库存聚合子查询等), 不显式声明相关性时 SQLAlchemy 的自动推断有歧义风险。 ★ 使用约束:必须**嵌入**一个 FROM 含该物料表的查询里使用,例如 query.filter(~active_purchase_exists(MaterialBase.id)) 单独 compile 这个表达式时没有外层查询可供相关,SQLAlchemy 会把 material_base 塞进内层 FROM 形成笛卡尔积 —— 那是编译期假象, 不影响嵌入使用的正确性,但**不要**脱离查询单独执行它。 """ from app.models.purchase import PurchaseRequest received = received_quantity_subquery() return ( db.session.query(PurchaseRequest.id) .outerjoin(received, PurchaseRequest.id == received.c.rid) .filter( PurchaseRequest.base_id == base_id_col, _active_condition(received), ) .correlate(base_id_col.table) .exists() ) def in_transit_subquery(): """ 每个物料的**在途数量**:{base_id: SUM(该单剩余待入库量)}。 单张活跃采购单的待入库量 = GREATEST(采购量 - 累计入库量, 0) ★ 为什么要 GREATEST 兜底:理论上活跃单的累计入库量不会超过采购量 (超收的单会因 _active_condition 判为不活跃而被排除),但历史上 出现过手工改状态、重复入库等脏数据,一个负值会**静默地**把别的单的 缺口抵掉,让待采购池少算在途、多建议采购量。兜到 0 代价极小。 ★ 与 active_purchase_exists 共用 _active_condition:两处若各写一遍, 将来改判定条件必然漏掉一边 —— 这个仓库已经因为「同一逻辑写两份」 吃过多次亏(见 material/list.vue 与 buyOdoo.vue 的双胞胎漂移)。 Returns: SQLAlchemy 子查询,列为 (base_id, intransit)。 调用方需 outerjoin 后再 coalesce(..., 0),因为没采购单的物料不在结果里。 """ from app.models.purchase import PurchaseRequest received = received_quantity_subquery() remaining = func.greatest( PurchaseRequest.quantity - func.coalesce(received.c.received, 0), 0, ) return ( db.session.query( PurchaseRequest.base_id.label('base_id'), func.sum(remaining).label('intransit'), ) .outerjoin(received, PurchaseRequest.id == received.c.rid) # base_id 为空的历史单无法归属到任何物料,直接排除, # 否则会聚成一个 base_id=NULL 的组白白参与 join .filter(PurchaseRequest.base_id.isnot(None)) .filter(_active_condition(received)) .group_by(PurchaseRequest.base_id) .subquery() )