防重复采购的唯一真相源。刻意回答两个不同的问题,别混用: - active_purchase_exists() 有没有人在买? → 展示用 - in_transit_subquery() 已经买了多少还没到? → 算术用 活跃单定义:status 0/1,或 status=3 且累计入库 < 采购量。 status=4(强制结案)刻意不在其中 —— 短交不补发、来料拒收不补发这类 异常单永远达不到采购量,靠库管强制结案解锁,避免物料被永久占用。 「部分到货」无需改表即可算出:StockBuy.request_id 早已存在,且 StockBuy.in_quantity 是入库原始量(不被出库扣减,区别于 stock_quantity), 按 request_id 聚合即得每张单的累计到货量。
164 lines
7.0 KiB
Python
164 lines
7.0 KiB
Python
"""
|
||
「活跃采购单」的判定与在途量计算 —— 防重复采购的唯一真相源。
|
||
|
||
本模块回答**两个不同的问题**,别混用:
|
||
|
||
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()
|
||
)
|