feat(return): 逆向物流数据模型与迁移

新增原单退回与不良品在管的持久化结构。

- TransOutbound 增 returned_quantity(numeric(19,4),非 float):该值参与
  「return_qty <= quantity - returned_quantity」判等,浮点误差会让反复部分
  退回后出现「已退满却判定未退满」的错判
- 新增 TransReturn:退回流水,每次退回写一条而非覆盖式更新。刻意与
  trans_borrow 划清界限——后者部分归还时会覆盖 return_time/operator,
  导致归还历史永久丢失
- 新增 TransDefectiveGoods:不良品在管台账。坏件全程不入库存表,因为
  status 是行级属性而质量是件级属性,把坏件加回原行只能整行打不良
  (实测 stock_buy 单行最大 4789 件、中位 8 件,整行打不良会凭空损失良品)
- 状态机:待处理 → 处理中 → {已回库|已报废|已闭环}。终态由累计去向推导
  而非「最后一次动作」——一批坏件可能既回库过又报废过,按最后动作定状态
  会产生误导
- restocked_qty/scrapped_qty 两列:二期用 quantity-remaining_qty 反推回库量,
  三期加入报废出口后该反推失效
- 审计白名单与模型预加载同步登记(监听器绑定 18 → 20 个模型)

迁移脚本均为纯追加式 DDL,含预检、回滚段与执行后核对。首个脚本用
COALESCE 包裹数量列——库存表允许数量为 NULL,而「NULL 大于 0」求值为
NULL 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。
This commit is contained in:
yueli
2026-09-16 15:45:11 +08:00
parent fbc9296056
commit 69c38a1bf7
6 changed files with 447 additions and 2 deletions

View File

@ -217,3 +217,198 @@ class TransScrap(db.Model):
'cost_at_scrap': float(self.cost_at_scrap) if self.cost_at_scrap is not None else None,
'total_loss': float(self.total_loss) if self.total_loss is not None else None,
}
# =============================================================================
# 原单退回(逆向物流)
# =============================================================================
# 设计要点
# --------
# 1. **流水不覆盖**:每一次退回写一条 trans_return,而不是在原记录上累加覆盖。
# 这是刻意与 trans_borrow 划清界限 —— 后者在部分归还时会把
# return_time / return_operator / return_signature 逐次覆盖,导致
# 「谁在什么时候还了多少」永久丢失。退回流水不能再犯同样的错。
#
# 2. **不良品不入库存表**:库存表的 status 是**行级**属性,而质量是**件级**
# 属性。把坏件加回原行,会让一行同时含良品与坏件 —— 只能整行打不良,
# 而实测 stock_buy 单行最大 4789 件(中位 8 件),整行打不良等于凭空
# 损失大量良品。故坏件全程存放在独立的 trans_defective_goods 台账里,
# 只有修好回库那一刻才回到原库存行。
#
# 3. **与维修模块解耦**:trans_repair 是 SN 单台粒度、且没有任何数量列,
# 承载不了「一批坏件」(实测 50.6% 的出库是多件,中位 2、最大 186)。
# 故坏件台账独立建表,不复用 trans_repair。
RETURN_TYPE_GOOD = '良品'
RETURN_TYPE_DEFECTIVE = '不良品'
VALID_RETURN_TYPES = (RETURN_TYPE_GOOD, RETURN_TYPE_DEFECTIVE)
class TransReturn(db.Model):
"""
原单退回流水。
一行 = 一次退回动作(不做覆盖式更新)。累计退回量另存于
trans_outbound.returned_quantity,本表负责回答「每一次是谁、何时、
退了多少、良品还是不良品」。
"""
__tablename__ = 'trans_return'
id = db.Column(db.Integer, primary_key=True)
outbound_id = db.Column(db.Integer, nullable=False, index=True) # 原出库明细 trans_outbound.id
stock_id = db.Column(db.Integer) # 原库存行 id(快照)
source_table = db.Column(db.String(50)) # 原库存表名(快照)
sku = db.Column(db.String(100)) # 冗余,避免列表页联表
return_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0)
return_type = db.Column(db.String(20), nullable=False) # '良品' | '不良品'
reason = db.Column(db.Text)
operator = db.Column(db.String(100))
return_time = db.Column(db.DateTime, default=beijing_time)
def to_dict(self):
return {
'id': self.id,
'outbound_id': self.outbound_id,
'stock_id': self.stock_id,
'source_table': self.source_table,
'sku': self.sku,
'return_qty': float(self.return_qty) if self.return_qty is not None else 0,
'return_type': self.return_type,
'reason': self.reason,
'operator': self.operator,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
}
# 状态机(三期升级):
# 待处理 ──┬─→ 处理中 ──┬─→ 已回库(全部回库,无报废)
# │ ├─→ 已报废(全部报废,无回库)
# │ └─→ 已闭环(回库与报废混合,在管量归零)
# └─(一次处置即为终态时,直接跳到对应的终态)
#
# ★ 三种终态而非单一「已闭环」,是为了让工作台一眼看出这批坏件的**去向**:
# 修好回到库存了、还是被销毁了。混合处置无法用单一去向描述,才归到「已闭环」。
DEFECTIVE_STATUS_PENDING = '待处理' # 刚退回,尚未做任何处置
DEFECTIVE_STATUS_IN_PROGRESS = '处理中' # 已部分回库/部分报废,仍有在管量
DEFECTIVE_STATUS_RESTOCKED = '已回库' # 全部回库,无报废
DEFECTIVE_STATUS_SCRAPPED = '已报废' # 全部报废,无回库
DEFECTIVE_STATUS_CLOSED = '已闭环' # 回库与报废混合,在管量归零
VALID_DEFECTIVE_STATUSES = (
DEFECTIVE_STATUS_PENDING,
DEFECTIVE_STATUS_IN_PROGRESS,
DEFECTIVE_STATUS_RESTOCKED,
DEFECTIVE_STATUS_SCRAPPED,
DEFECTIVE_STATUS_CLOSED,
)
# ★ 可继续处置的状态白名单(Fail-Closed:未列出的一律拒绝回库/报废)。
# 三种终态都不可再动:已回库→再回库就是凭空多一份库存;已报废→实物已销毁;
# 已闭环→在管量已归零。
OPEN_DEFECTIVE_STATUSES = (
DEFECTIVE_STATUS_PENDING,
DEFECTIVE_STATUS_IN_PROGRESS,
)
# 语义别名:回库与报废的准入白名单是同一组「未结案」状态
RESTOCKABLE_DEFECTIVE_STATUSES = OPEN_DEFECTIVE_STATUSES
SCRAPPABLE_DEFECTIVE_STATUSES = OPEN_DEFECTIVE_STATUSES
def defective_close_status(restocked_qty, scrapped_qty):
"""
在管量归零时,由累计去向推导终态。
★ 为什么需要它:一次坏件批次可能既回库了一部分、又报废了剩余部分。
此时 remaining_qty 归零,但既不是「已回库」也不是「已报废」——
按最后一次动作定状态会产生误导(最后报废 ≠ 整批报废)。
故由累计量推导,语义稳定且与动作顺序无关。
"""
restocked = float(restocked_qty or 0)
scrapped = float(scrapped_qty or 0)
if restocked > 0 and scrapped > 0:
return DEFECTIVE_STATUS_CLOSED
if scrapped > 0:
return DEFECTIVE_STATUS_SCRAPPED
return DEFECTIVE_STATUS_RESTOCKED
class TransDefectiveGoods(db.Model):
"""
不良品在管台账。
一行 = 一批同源坏件。支持**部分回库**:remaining_qty 随每次回库递减,
归零才算整批回库完成。
本表与库存表解耦:坏件在管期间不占用任何库存行的数量,也不改其 status。
回库时才按 source_table + stock_id 回到原库存行。
"""
__tablename__ = 'trans_defective_goods'
id = db.Column(db.Integer, primary_key=True)
# --- 来源追溯 ---
return_id = db.Column(db.Integer, index=True) # trans_return.id
outbound_id = db.Column(db.Integer, index=True) # trans_outbound.id
# --- 回库目标(原库存行)---
source_table = db.Column(db.String(50), nullable=False)
stock_id = db.Column(db.Integer, nullable=False)
# --- 物料快照(回库目标行可能被删,此处保留可读信息)---
base_id = db.Column(db.Integer)
sku = db.Column(db.String(100))
material_name = db.Column(db.String(200))
spec_model = db.Column(db.String(255))
# --- 数量 ---
# ★ 不变式:restocked_qty + scrapped_qty + remaining_qty = quantity
# 三个去向列相互独立,不可互推 —— 二期曾用 quantity - remaining_qty
# 反推回库量,三期加入报废出口后该反推即失效。
quantity = db.Column(db.Numeric(19, 4), nullable=False, default=0) # 进入在管时的原始数量
remaining_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # 仍在管数量
restocked_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # [三期] 累计已回库
scrapped_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # [三期] 累计已报废
# --- 状态与归属 ---
status = db.Column(db.String(20), nullable=False, default=DEFECTIVE_STATUS_PENDING)
company_name = db.Column(db.String(255), index=True) # 行级隔离:本表承载实物,按库存表口径存公司
reason = db.Column(db.Text)
operator = db.Column(db.String(100))
remark = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=beijing_time)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time)
def to_dict(self):
qty = float(self.quantity) if self.quantity is not None else 0
remain = float(self.remaining_qty) if self.remaining_qty is not None else 0
restocked = float(self.restocked_qty) if self.restocked_qty is not None else 0
scrapped = float(self.scrapped_qty) if self.scrapped_qty is not None else 0
return {
'id': self.id,
'return_id': self.return_id,
'outbound_id': self.outbound_id,
'source_table': self.source_table,
'stock_id': self.stock_id,
'base_id': self.base_id,
'sku': self.sku,
'material_name': self.material_name,
'spec_model': self.spec_model,
'quantity': qty,
'remaining_qty': remain,
# ★ 三个去向列各自独立取值。改造前 restocked_qty 由
# quantity - remaining_qty 反推,三期加入报废出口后会算错。
'restocked_qty': restocked,
'scrapped_qty': scrapped,
'status': self.status,
'company_name': self.company_name,
'reason': self.reason,
'operator': self.operator,
'remark': self.remark,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
}