Files
KCGL/inventory-backend/app/models/transaction.py
yueli bfd0db791c feat(scrap): 报废原因分类 + 角色级审批
报废要回答「这笔损失出在哪个环节」,并让主管审批不再依赖逐个指定人。

- 新增「报废原因分类」字段(scrap_approval + trans_scrap 各一列),
  只有两个互斥口径:生产报废(走 Track 的)/ 库存报废(MOM 自身流程的)。
  不传即库存报废 —— 这条二分法在写入那一刻就成立,不依赖任何推导。
  ⚠️ 不能从 source_table 推导:Track 的生产报废与手工的不良品退回共用
     同一张 trans_defective_goods 表,推导会把生产损失算成库存损失。
- scrap_approval 加 company_name / source_ref:前者是公司隔离快照,
  后者是外部单据的幂等锚点(Redis 未部署,prevent_double_submit 全程
  fail-open,唯一索引是唯一防线)。
- 审批从「只认 type=user」放宽到「user 或 role」——主管角色都能审,
  谁审就记谁。★ 空名单依然拒绝所有人(Fail-Closed),这是历史
  「名单为空则人人可审」漏洞的修复点,不得改回 fail-open。
- 角色级放行必须配公司隔离:6 个主管里 IRIS 5 个、LICA 1 个,
  不隔离就是跨公司审批通道。
- trans_return 加 source_ref(幂等锚点)。

迁移:db_migrations/phase12(建列)+ phase13(存量空分类回填为库存报废)。
2026-09-23 15:17:44 +08:00

635 lines
30 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.

from app.extensions import db, beijing_time
from datetime import datetime
from sqlalchemy import func
from functools import lru_cache
@lru_cache(maxsize=512)
def _borrow_user_name(uid):
"""按用户 ID 取用户名(带缓存,避免列表页 N+1)"""
try:
from app.models.system import SysUser
u = SysUser.query.get(uid)
return u.username if u else None
except Exception:
return None
def _display_borrow_operator(op):
"""
**经手库管**的展示映射(注意:不是「归还人」—— 两者是不同的人)。
该字段历史上存过三种口径,统一归一到「展示名」:
· 数字 user id(更早期) → 反查 sys_user 取姓名;
· 完整 username「姓名/拼音」 → 取斜杠前段(与全站展示口径一致);
· 已经是展示名 → 原样返回。
"""
if not op:
return op
s = str(op).strip()
if s.isdigit():
mapped = _borrow_user_name(int(s))
if mapped:
return mapped.split('/')[0] if '/' in mapped else mapped
return op
return s.split('/')[0] if '/' in s else op
class TransBorrow(db.Model):
__tablename__ = 'trans_borrow'
id = db.Column(db.Integer, primary_key=True)
borrow_no = db.Column(db.String(100))
sku = db.Column(db.String(100))
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
barcode = db.Column(db.String(100))
quantity = db.Column(db.Numeric(19, 4))
returned_quantity = db.Column(db.Numeric(19, 4), default=0)
borrower_name = db.Column(db.String(100))
borrow_time = db.Column(db.DateTime, default=beijing_time)
borrow_signature = db.Column(db.Text)
# [一期收口] 执行借出(扫码发货)的**库管操作人**姓名。
# 此前 execute_dispatch 的 operator_name 形参被接收后从未落库,责任链上
# 「谁经手发货的」一直缺失。仅展示/追溯用,不参与权限判定或持有人校验。
# ★ 该列上线前的历史记录为 NULL(执行人信息从未被采集,无从回填)。
dispatch_operator = db.Column(db.String(100))
expected_return_time = db.Column(db.DateTime)
is_returned = db.Column(db.Boolean, default=False)
return_time = db.Column(db.DateTime)
return_operator = db.Column(db.String(100))
return_signature = db.Column(db.Text)
return_location = db.Column(db.String(100))
# [新增] 借出时的库位快照(从源库存记录带出,便于历史追溯)
location = db.Column(db.String(100))
status = db.Column(db.String(20), default='borrowed')
remark = db.Column(db.Text)
# =========================================================================
# 借库转交(一期)—— 身份 ID 锚点
#
# ★ 为什么 ID 与姓名快照并存:
# ID 是**唯一**的身份锚点( borrower_name 是字符串,实测 85 行里 18 个姓名
# 映射到 18 个人,一旦重名责任链即断);姓名字段保留为**展示快照**,
# 避免列表页逐行回查 sys_user 造成 N+1,且用户行被删除后台账仍可读
# (与 trans_return.company_name 同一处理方式)。
#
# ★ 为什么 _id 允许 NULL:
# 仅迁移前的历史行可能为 NULL —— 迁移脚本按「姓名唯一命中 sys_user」回填,
# 85 行中 84 行成功,1 行(「测试01」)姓名无法映射故留空。
# 新数据由 execute_dispatch 强制写入 borrower_id,不存在 NULL。
#
# ★ borrower_id 与 current_holder_id 的区别:
# borrower_id = 初始借用人,写一次不再变(回答「这单最初谁借的」);
# current_holder_id = 当前实际持有人,转交会推进它(回答「东西现在在谁手上」)。
# 归还走完后 current_holder_id 被清空,borrower_id 保留作历史。
# =========================================================================
borrower_id = db.Column(db.Integer, index=True) # 初始借用人ID
current_holder_id = db.Column(db.Integer, index=True) # 当前持有人ID
current_holder_name = db.Column(db.String(100)) # 当前持有人姓名快照
def to_dict(self):
returned_qty = float(self.returned_quantity) if self.returned_quantity is not None else 0
total_qty = float(self.quantity) if self.quantity is not None else 0
pending_qty = total_qty - returned_qty
return {
'id': self.id,
'borrow_no': self.borrow_no,
'sku': self.sku,
'source_table': self.source_table,
'stock_id': self.stock_id,
'barcode': self.barcode,
'quantity': total_qty,
'returned_quantity': returned_qty,
'pending_quantity': pending_qty,
'borrower_id': self.borrower_id,
'borrower_name': self.borrower_name,
# ★ 当前持有人:转交后前端据此展示「东西在谁手上」并预填归还人
'current_holder_id': self.current_holder_id,
'current_holder_name': self.current_holder_name,
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
# 执行借出的库管操作人(一期收口;历史行为 NULL)
'dispatch_operator': self.dispatch_operator,
'borrow_signature': self.borrow_signature,
'expected_return_time': self.expected_return_time.strftime('%Y-%m-%d %H:%M') if self.expected_return_time else None,
'is_returned': self.is_returned,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else None,
'return_operator': _display_borrow_operator(self.return_operator),
'return_signature': self.return_signature,
'return_location': self.return_location,
'location': self.location or '',
'status': self.status,
'remark': self.remark,
}
@classmethod
def get_borrowed_quantity(cls, source_table, stock_id):
"""
获取指定库存记录(source_table 和 stock_id)的借出未还数量总和。
返回浮点数,若无借出记录则返回 0.0。
"""
result = db.session.query(func.sum(cls.quantity)).filter(
cls.source_table == source_table,
cls.stock_id == stock_id,
cls.is_returned == False
).scalar()
return float(result) if result is not None else 0.0
# =============================================================================
# 借库转交流水(一期)
# =============================================================================
# 设计要点
# --------
# 1. **流水不覆盖**:一行 = 一次转交动作。trans_borrow 是单行模型,只能存
# 「当前持有人」一个值;A→B→C 的完整持有链只能由本表回答。
# 若改为在 trans_borrow 上覆盖 current_holder,等于重演归还失忆症。
#
# 2. **一期仅支持整单全量转交**:单行模型无法同时追踪两个持有人,
# 部分转交会让 current_holder 语义撕裂(半单归 A、半单归 B)。
# 故 transfer_qty 一期恒等于 quantity - returned_quantity,
# 服务层对部分转交直接拒绝。未来若需部分转交,需改为按 quantity 拆行。
#
# 3. **不碰库存**:转交是纯持有权变更,实物不出入库,全程不得触碰
# available_quantity / stock_quantity。
# =============================================================================
# 转交状态机:发起只落 PENDING,接收人 accept 后才真正转移责任
TRANSFER_STATUS_PENDING = 'PENDING' # 待接收(主表 current_holder 未动)
TRANSFER_STATUS_ACCEPTED = 'ACCEPTED' # 已接收(主表已转移)
TRANSFER_STATUS_REJECTED = 'REJECTED' # 已拒绝(主表不动,责任仍在原持有人)
class TransBorrowTransfer(db.Model):
"""借库转交流水:一行 = 一次转交动作,不做覆盖式更新。"""
__tablename__ = 'trans_borrow_transfer'
id = db.Column(db.Integer, primary_key=True)
# 关联主表(不建 FK:与 trans_return 一致,台账必须能独立存活)
# ★ 代表明细:一次转交要覆盖该单号下**多行**,单行 ID 表达不了覆盖范围,
# 故覆盖范围以 borrow_no 为准,此列仅供追溯。
borrow_id = db.Column(db.Integer, nullable=False, index=True)
# ★ 单据身份:accept 时据此批量更新该单全部未还明细的持有人
borrow_no = db.Column(db.String(100), index=True)
# ★ 状态机:见文件顶部常量
status = db.Column(db.String(20), nullable=False, default=TRANSFER_STATUS_PENDING, index=True)
# ★ 发起方看到「被拒绝」提醒并确认的时间。
# 被拒绝时物品责任仍在发起方手上 —— 他若不查列表就会误以为已经交接出去,
# 责任链出现静默断点。故必须告知,且必须能标记「已告知」,
# 否则发起方每次登录都收到同一条提醒,从提醒退化成骚扰。
reject_seen_at = db.Column(db.DateTime)
# ★ 拒收原因独立成列。此前拼在 remark 里("...\n[拒绝原因] xxx"),
# 前端拿到一坨字符串无法区分「转交备注」与「拒收原因」;
# 靠字符串约定还原结构化信息既脆弱(用户自己也可能打出该标记),
# 又没法参与查询统计。存储层能表达的东西不靠约定去猜。
reject_reason = db.Column(db.Text)
# 转出方(= 转交前的 current_holder)
from_user_id = db.Column(db.Integer)
from_user_name = db.Column(db.String(100))
# 接收方(= 转交后的 current_holder)
to_user_id = db.Column(db.Integer)
to_user_name = db.Column(db.String(100))
# 一期恒等于 quantity - returned_quantity(整单全量转交)
transfer_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0)
transfer_time = db.Column(db.DateTime, default=beijing_time)
operator_name = db.Column(db.String(100)) # 执行转交的库管
remark = db.Column(db.Text)
def to_dict(self):
return {
'id': self.id,
'borrow_id': self.borrow_id,
'borrow_no': self.borrow_no,
'status': self.status,
'status_text': {
TRANSFER_STATUS_PENDING: '待接收',
TRANSFER_STATUS_ACCEPTED: '已接收',
TRANSFER_STATUS_REJECTED: '已拒绝',
}.get(self.status, self.status),
# 仅供发起方「被拒绝」提醒使用,判断是否需要告知由 reject_seen_at 决定
'reject_seen': self.reject_seen_at is not None,
'reject_reason': self.reject_reason,
'from_user_id': self.from_user_id,
'from_user_name': self.from_user_name,
'to_user_id': self.to_user_id,
'to_user_name': self.to_user_name,
'transfer_qty': float(self.transfer_qty) if self.transfer_qty is not None else 0,
'transfer_time': self.transfer_time.strftime('%Y-%m-%d %H:%M:%S') if self.transfer_time else None,
'operator_name': self.operator_name,
'remark': self.remark,
}
# =============================================================================
# 借库归还流水
# =============================================================================
# 为什么需要它
# ------------
# trans_borrow 原先在部分归还时把 return_time / return_operator /
# return_signature **逐次覆盖**,导致「谁在什么时候还了多少」只剩最后一次
# (出库退回模块的 TransReturn 已就同一问题另建流水,见本文件上方注释)。
# 本表按次记录归还动作,根治该失忆症。
#
# ★ 主表字段的定位(未废弃,但降级):
# returned_quantity / is_returned / status —— 仍是**累计快照**,聚合语义正确,
# 列表页的「未还/已还」tab 判定依赖它们,继续维护;
# return_time / return_operator / return_signature —— 降级为「最近一次归还」
# 展示快照,records.vue 的归还人/归还时间列依赖它们,继续刷新;
# 逐次明细的**权威来源**是本表。
#
# ★ returner_id 与 operator_name 是两个人:
# returner_id = 实际把东西交回窗口的人,写入前已强校验 == current_holder_id;
# operator_name = 经手办理还库的库管。
# =============================================================================
class TransBorrowReturn(db.Model):
"""借库归还流水:一行 = 一次归还动作。"""
__tablename__ = 'trans_borrow_return'
id = db.Column(db.Integer, primary_key=True)
borrow_id = db.Column(db.Integer, nullable=False, index=True) # 关联 trans_borrow.id
returner_id = db.Column(db.Integer, index=True) # 实际归还人(==current_holder)
return_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0)
return_time = db.Column(db.DateTime, default=beijing_time)
operator_name = db.Column(db.String(100)) # 经手库管
def to_dict(self):
return {
'id': self.id,
'borrow_id': self.borrow_id,
'returner_id': self.returner_id,
'return_qty': float(self.return_qty) if self.return_qty is not None else 0,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
'operator_name': self.operator_name,
}
class TransRepair(db.Model):
__tablename__ = 'trans_repair'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
# 维修单号 (新增)
repair_no = db.Column(db.String(50), nullable=True, unique=True)
# 关联基础信息 (新增)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=True)
# SKU 保留
sku = db.Column(db.String(100))
# 物料名称 (独立录入时使用,非关联base_id)
material_name = db.Column(db.String(200))
# 序列号SN (新增,用于单台追溯)
serial_number = db.Column(db.String(100), nullable=True)
# 来源追溯 (兼容旧数据)
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
is_self_made = db.Column(db.Boolean, default=False)
related_product_id = db.Column(db.Integer)
# 入库/接收时间
arrival_date = db.Column(db.Date)
# 维修状态 (新增)
repair_status = db.Column(db.String(50), default='待检测')
# 客户反馈
fault_description = db.Column(db.Text)
# 预计修复时间
expected_repair_time = db.Column(db.String(100))
# 维修日志/结果
repair_result = db.Column(db.Text)
# 维修人
repair_manager = db.Column(db.String(100))
# 出库交付时间
shipping_date = db.Column(db.Date)
# 客户名/来源
related_contract_id = db.Column(db.String(100))
# 客户名称 (新增)
customer_name = db.Column(db.String(100))
# 客户所在地 (新增)
customer_location = db.Column(db.String(255))
# 成本与售价
cost_price = db.Column(db.Numeric(19, 4))
sale_price = db.Column(db.Numeric(19, 4))
# 数据隔离 (新增)
company_id = db.Column(db.Integer, nullable=True)
# 关联关系
base = db.relationship('MaterialBase', backref='repairs')
def to_dict(self):
return {
'id': self.id,
'repair_no': self.repair_no,
'base_id': self.base_id,
'sku': self.sku,
'material_name': self.material_name,
'serial_number': self.serial_number,
'source_table': self.source_table,
'stock_id': self.stock_id,
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None,
'repair_status': self.repair_status,
'expected_repair_time': self.expected_repair_time,
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
'is_self_made': self.is_self_made,
'related_product_id': self.related_product_id,
'related_contract_id': self.related_contract_id,
'customer_name': self.customer_name,
'customer_location': self.customer_location,
'repair_manager': self.repair_manager,
'fault_description': self.fault_description,
'repair_result': self.repair_result,
'cost_price': float(self.cost_price) if self.cost_price is not None else None,
'sale_price': float(self.sale_price) if self.sale_price is not None else None,
'company_id': self.company_id,
}
class TransScrap(db.Model):
__tablename__ = 'trans_scrap'
id = db.Column(db.Integer, primary_key=True)
sku = db.Column(db.String(100))
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
quantity = db.Column(db.Numeric(19, 4))
reason = db.Column(db.Text)
operator_name = db.Column(db.String(100))
operation_time = db.Column(db.DateTime, default=beijing_time)
approver_name = db.Column(db.String(100))
approval_status = db.Column(db.String(20), default='pending')
cost_at_scrap = db.Column(db.Numeric(19, 4))
total_loss = db.Column(db.Numeric(19, 4))
# ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql)
scrap_request_no = db.Column(db.String(100), index=True)
# 报废原因分类码,执行时从 scrap_approval.reason_category 带出(见 scrap_sources._ledger_kwargs)。
# 「统计生产报废金额」按此列分组,不要靠 reason 自由文本去匹配。
# DDL 见 db_migrations/phase12_production_scrap.sql
reason_category = db.Column(db.String(50), index=True)
def to_dict(self):
return {
'id': self.id,
'sku': self.sku,
'source_table': self.source_table,
'stock_id': self.stock_id,
'quantity': float(self.quantity) if self.quantity is not None else None,
'reason': self.reason,
'reason_category': self.reason_category,
'operator_name': self.operator_name,
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
'approver_name': self.approver_name,
'approval_status': self.approval_status,
'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)
# ★ 公司快照:退回发生时的所属公司。
# 不靠 join 推 —— 隔离判定的链路是
# trans_return → trans_outbound → (source_table, stock_id) → 库存表 → 物料主表
# 而库存行会被入库模块**物理删除**(实测 1077 条出库记录中已有 7 条悬空),
# 链路一断,记录就会对普通用户静默消失。审计视图静默丢数据不可接受。
# 与 trans_defective_goods.company_name 同一处理方式。
# DDL 见 db_migrations/add_return_view_support.sql
company_name = db.Column(db.String(255), index=True)
# ★ 外部系统唯一引用,格式 <company>:<外部单号>。
# 外部接口(Track → MOM 生产报废)据此判重。之所以不靠 prevent_double_submit:
# 它依赖 Redis,而 compose 里没有 redis 服务 → redis_client 恒为 None →
# 装饰器全程 fail-open。唯一索引是唯一的并发防线。
# DDL 见 db_migrations/phase12_production_scrap.sql
source_ref = db.Column(db.String(100), index=True)
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,
'company_name': self.company_name,
'source_ref': self.source_ref or '',
}
# 状态机(三期升级):
# 待处理 ──┬─→ 处理中 ──┬─→ 已回库(全部回库,无报废)
# │ ├─→ 已报废(全部报废,无回库)
# │ └─→ 已闭环(回库与报废混合,在管量归零)
# └─(一次处置即为终态时,直接跳到对应的终态)
#
# ★ 三种终态而非单一「已闭环」,是为了让工作台一眼看出这批坏件的**去向**:
# 修好回到库存了、还是被销毁了。混合处置无法用单一去向描述,才归到「已闭环」。
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))
# 原库位 / 批次(序列号) 快照 —— 退回时取自源库存行
# (DDL 见 db_migrations/phase14_defective_goods_location_snapshot.sql)
#
# ★ 口径是「**原库位**」:这批货最初在哪,而不是坏件当前所在的不良品区
# (不良品区位置本系统未记录)。与三张库存表来源的报废记录口径一致。
# ★ batch_number 对 stock_product 来源存的是 serial_number(成品表无
# batch_number 列),与 scrap.py 的 _from_stock 取值口径一致。
# ★ 加这两列是为了让报表**不必**依赖源库存行存活 —— 源行会被入库模块
# 物理删除,届时回查落空,只能靠这里的快照。存量行由上述迁移回填。
warehouse_location = db.Column(db.String(100))
batch_number = db.Column(db.String(100))
# --- 数量 ---
# ★ 不变式: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,
'warehouse_location': self.warehouse_location or '',
'batch_number': self.batch_number or '',
'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,
}