Files
KCGL/inventory-backend/app/models/transaction.py
yueli 681607bd43 feat(borrow): 拒收原因独立成列,与转交备注彻底分离
背景
----
拒收原因此前是**拼进 remark** 的:
    transfer.remark = f"{remark}\n[拒绝原因] {reason}"
前端拿到的是「3333\n[拒绝原因] 5555」这样一坨,时间线上两句挤在一起,
无法分辨哪句是发起备注、哪句是对方拒收的原因。

改动
----
· trans_borrow_transfer 新增 reject_reason text 列;
  reject_transfer 改为写入该列,不再拼进 remark。
· 存量按 '[拒绝原因] ' 标记切分回填(实测仅 #22:
  remark 3333 / reject_reason 5555)。
· 时间线事件带出 reject_reason,前端才能分行展示。

★ 为什么拆列而不是让前端解析字符串
  1) 拼接格式是隐式契约:改分隔符或加前缀,前端解析就静默失效且难排查;
  2) 用户完全可能在备注里自己打出 '[拒绝原因]' 字样,按标记切分必然误判 ——
     已加测试用例锁定该场景;
  3) 结构化字段才能参与查询与统计(如按拒收原因归类)。
  存储层能表达的东西,不该靠字符串约定去还原。

★ 一个迁移期踩到的坑:btrim 默认只去空格、不去换行。
  拼接留下的是 '3333\n',只写 btrim(x) 会残留换行;必须显式给出字符集
  btrim(x, E' \t\r\n')。已修正脚本并对存量做了一次清理。

验证(7 项断言全通过)
  备注不被污染、原因写独立列、无原因时为 None、
  用户备注含同名标记也不误判、库存零副作用、数据零残留。
2026-09-17 10:46:31 +08:00

597 lines
27 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 → 映射为用户名;新数据存姓名则原样返回"""
if op and str(op).strip().isdigit():
mapped = _borrow_user_name(int(op))
return mapped if mapped else op
return 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)
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,
'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)
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,
}
# 状态机(三期升级):
# 待处理 ──┬─→ 处理中 ──┬─→ 已回库(全部回库,无报废)
# │ ├─→ 已报废(全部报废,无回库)
# │ └─→ 已闭环(回库与报废混合,在管量归零)
# └─(一次处置即为终态时,直接跳到对应的终态)
#
# ★ 三种终态而非单一「已闭环」,是为了让工作台一眼看出这批坏件的**去向**
# 修好回到库存了、还是被销毁了。混合处置无法用单一去向描述,才归到「已闭环」。
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,
}