Files
KCGL/inventory-backend/app/utils/purchase_activity.py
yueli fc5e62c848 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 聚合即得每张单的累计到货量。
2026-09-18 14:30:40 +08:00

164 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
「活跃采购单」的判定与在途量计算 —— 防重复采购的唯一真相源。
本模块回答**两个不同的问题**,别混用:
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()
)