feat(purchase): 新增「活跃采购单」判定与在途量计算模块

防重复采购的唯一真相源。刻意回答两个不同的问题,别混用:
- active_purchase_exists()  有没有人在买?      → 展示用
- in_transit_subquery()     已经买了多少还没到? → 算术用

活跃单定义:status 0/1,或 status=3 且累计入库 < 采购量。
status=4(强制结案)刻意不在其中 —— 短交不补发、来料拒收不补发这类
异常单永远达不到采购量,靠库管强制结案解锁,避免物料被永久占用。

「部分到货」无需改表即可算出:StockBuy.request_id 早已存在,且
StockBuy.in_quantity 是入库原始量(不被出库扣减,区别于 stock_quantity),
按 request_id 聚合即得每张单的累计到货量。
This commit is contained in:
yueli
2026-09-18 14:30:40 +08:00
parent caaa1571d4
commit fc5e62c848

View File

@ -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()
)