diff --git a/inventory-backend/app/utils/purchase_activity.py b/inventory-backend/app/utils/purchase_activity.py new file mode 100644 index 0000000..b9a0c72 --- /dev/null +++ b/inventory-backend/app/utils/purchase_activity.py @@ -0,0 +1,163 @@ +""" +「活跃采购单」的判定与在途量计算 —— 防重复采购的唯一真相源。 + +本模块回答**两个不同的问题**,别混用: + + 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() + )