Files
KCGL/inventory-backend/app/models/transaction.py

609 lines
28 KiB
Python
Raw Normal View History

from app.extensions import db, beijing_time
2026-02-04 13:30:07 +08:00
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):
fix(borrow): 借还记录「归还人」列错标成了经手库管,拆成两列 问题 ---- 列表里那一列标着「归还人」,读的却是 trans_borrow.return_operator —— 而该字段 存的是**办理还库的库管**,不是来还东西的人。两者本就是不同的人: · returner_id(trans_borrow_return)—— 把东西交回窗口的人,已校验 == 当时持有人 · return_operator —— 经手办理的库管 实测(借用行 120):return_operator = 杜邢宸/duxingchen(库管), 而实际归还人是 returner_id = 21(测试)—— 页面却显示成了「杜邢宸」。 改动 ---- 一、后端 get_records 增补 returners:从 trans_borrow_return 取 returner_id 并 反查 sys_user 得到姓名(去重按明细挂回)。批量查一次,不做 N+1。 二、前端拆成两列,各自名副其实: 「归还人」 ← returners(后端新增) 「经手库管」 ← return_operators(原列改为正确标签) 三、顺带修展示口径:return_operator 存的是**完整 username**(高闯/gaochuang), 未按全站口径截断。_display_borrow_operator 现统一归一到展示名 (数字 id 反查 / 姓名、斜杠前段 / 已是展示名 原样),并修正其 docstring —— 它原本也把该字段称作「归还人」,是同一个误解的源头。 ★ 历史数据的现实 实际归还人流水是二期才建的,**历史归还没有这个记录**。这部分行的「归还人」 显示为空并挂 tooltip 说明「该笔归还发生在实际归还人记录上线之前」—— 刻意不拿库管的名字顶上,那正是本次要修的错。 验证 BOR-20260918-0001 → 归还人=['测试']、经手库管='杜邢宸' ✓ 两者分开 BOR-20260914-0001 → 归还人=[](历史)、经手库管='高闯' ✓ 归一化:'高闯/gaochuang'→'高闯'、'21'→'测试' ✓ 前端 vite build 通过;本次无需 DB 迁移。
2026-09-18 09:43:25 +08:00
"""
**经手库管**的展示映射(注意:不是「归还人」—— 两者是不同的人)。
该字段历史上存过三种口径,统一归一到「展示名」:
· 数字 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
2026-02-04 13:30:07 +08:00
2026-02-04 14:29:59 +08:00
2026-02-04 13:30:07 +08:00
class TransBorrow(db.Model):
__tablename__ = 'trans_borrow'
2026-02-04 14:29:59 +08:00
2026-02-04 13:30:07 +08:00
id = db.Column(db.Integer, primary_key=True)
2026-02-06 17:11:47 +08:00
borrow_no = db.Column(db.String(100))
sku = db.Column(db.String(100))
2026-02-04 13:30:07 +08:00
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
2026-02-06 17:11:47 +08:00
barcode = db.Column(db.String(100))
2026-02-04 13:30:07 +08:00
quantity = db.Column(db.Numeric(19, 4))
returned_quantity = db.Column(db.Numeric(19, 4), default=0)
2026-02-06 17:11:47 +08:00
borrower_name = db.Column(db.String(100))
borrow_time = db.Column(db.DateTime, default=beijing_time)
borrow_signature = db.Column(db.Text)
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
# [一期收口] 执行借出(扫码发货)的**库管操作人**姓名。
# 此前 execute_dispatch 的 operator_name 形参被接收后从未落库,责任链上
# 「谁经手发货的」一直缺失。仅展示/追溯用,不参与权限判定或持有人校验。
# ★ 该列上线前的历史记录为 NULL(执行人信息从未被采集,无从回填)。
dispatch_operator = db.Column(db.String(100))
2026-02-04 13:30:07 +08:00
expected_return_time = db.Column(db.DateTime)
2026-02-06 17:11:47 +08:00
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))
2026-02-04 14:29:59 +08:00
status = db.Column(db.String(20), default='borrowed')
2026-02-06 17:11:47 +08:00
remark = db.Column(db.Text)
2026-02-04 13:30:07 +08:00
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
# =========================================================================
# 借库转交(一期)—— 身份 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)) # 当前持有人姓名快照
2026-02-04 13:30:07 +08:00
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
2026-02-04 13:30:07 +08:00
return {
'id': self.id,
2026-02-06 17:11:47 +08:00
'borrow_no': self.borrow_no,
2026-02-04 13:30:07 +08:00
'sku': self.sku,
'source_table': self.source_table,
'stock_id': self.stock_id,
2026-02-06 17:11:47 +08:00
'barcode': self.barcode,
'quantity': total_qty,
'returned_quantity': returned_qty,
'pending_quantity': pending_qty,
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
'borrower_id': self.borrower_id,
2026-02-04 13:30:07 +08:00
'borrower_name': self.borrower_name,
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
# ★ 当前持有人:转交后前端据此展示「东西在谁手上」并预填归还人
'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,
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
# 执行借出的库管操作人(一期收口;历史行为 NULL)
'dispatch_operator': self.dispatch_operator,
2026-02-06 17:11:47 +08:00
'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,
2026-02-06 17:11:47 +08:00
'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),
2026-02-06 17:11:47 +08:00
'return_signature': self.return_signature,
'return_location': self.return_location,
'location': self.location or '',
2026-02-06 17:11:47 +08:00
'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
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
# =============================================================================
# 借库转交流水(一期)
# =============================================================================
# 设计要点
# --------
# 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' # 已拒绝(主表不动,责任仍在原持有人)
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
class TransBorrowTransfer(db.Model):
"""借库转交流水:一行 = 一次转交动作,不做覆盖式更新。"""
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
__tablename__ = 'trans_borrow_transfer'
id = db.Column(db.Integer, primary_key=True)
# 关联主表(不建 FK:与 trans_return 一致,台账必须能独立存活)
# ★ 代表明细:一次转交要覆盖该单号下**多行**,单行 ID 表达不了覆盖范围,
# 故覆盖范围以 borrow_no 为准,此列仅供追溯。
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
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)
feat(borrow): 拒收须告知发起方(责任回到他手上,不能静默) 背景 ---- 双向握手补上了「接收人确认」,却只做了单向告知:接收人能看到待办,发起方却 对结果一无所知。**被拒绝时物品责任仍在发起方手上** —— 他若不主动查列表, 就会误以为已经交接出去,责任链出现静默断点。 (ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。) 改动 ---- · trans_borrow_transfer 新增 reject_seen_at(NULL 且 REJECTED = 尚未告知)。 ★ 为什么需要持久标记而不是前端去重:换台电脑、换个浏览器就会重新提醒; 而这条信息的分量(责任归属)值得一个持久标记。 ★ 存量已拒绝的流水一律标记为已告知:它们产生于本功能上线之前, 追溯提醒只会打扰(实测仅 1 条:#22,验收时的测试数据)。 · get_unseen_rejects(user_id):返回「我发起、被拒、尚未告知我」的转交, 并批量解析物料名 —— 只说「某笔转交被拒」发起方仍不知是哪件东西还在 自己手上,必须让他一眼认出来。 · ack_rejects(user_id, ids):发起方确认后写 reject_seen_at,幂等。 · GET .../transfer/pending-count 的响应并入 rejects:与待接收数量共用同一次 轮询,前端不必多打一个请求。 · POST .../transfer/reject-ack:无 permission_required,同 accept/reject。 顺带补一处同源显示缺口 ---- 流转时间线里,被拒绝的转交与成功的长得一模一样 —— 发起方翻记录时同样会 误判。现将转交状态一并带出时间线事件。 验证(15 项断言全通过) ---- 发起方收到待告知的拒绝(含物料名/接收人/拒绝原因);接收人与无关人看不到; ack 后不再提醒且幂等;ACCEPTED 不产生告知;None/非法 user_id 均安全返回; 库存零副作用、数据零残留。
2026-09-17 10:44:00 +08:00
# ★ 发起方看到「被拒绝」提醒并确认的时间。
# 被拒绝时物品责任仍在发起方手上 —— 他若不查列表就会误以为已经交接出去,
# 责任链出现静默断点。故必须告知,且必须能标记「已告知」,
# 否则发起方每次登录都收到同一条提醒,从提醒退化成骚扰。
reject_seen_at = db.Column(db.DateTime)
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
# ★ 拒收原因独立成列。此前拼在 remark 里("...\n[拒绝原因] xxx"),
# 前端拿到一坨字符串无法区分「转交备注」与「拒收原因」;
# 靠字符串约定还原结构化信息既脆弱(用户自己也可能打出该标记),
# 又没法参与查询统计。存储层能表达的东西不靠约定去猜。
reject_reason = db.Column(db.Text)
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
# 转出方(= 转交前的 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),
feat(borrow): 拒收须告知发起方(责任回到他手上,不能静默) 背景 ---- 双向握手补上了「接收人确认」,却只做了单向告知:接收人能看到待办,发起方却 对结果一无所知。**被拒绝时物品责任仍在发起方手上** —— 他若不主动查列表, 就会误以为已经交接出去,责任链出现静默断点。 (ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。) 改动 ---- · trans_borrow_transfer 新增 reject_seen_at(NULL 且 REJECTED = 尚未告知)。 ★ 为什么需要持久标记而不是前端去重:换台电脑、换个浏览器就会重新提醒; 而这条信息的分量(责任归属)值得一个持久标记。 ★ 存量已拒绝的流水一律标记为已告知:它们产生于本功能上线之前, 追溯提醒只会打扰(实测仅 1 条:#22,验收时的测试数据)。 · get_unseen_rejects(user_id):返回「我发起、被拒、尚未告知我」的转交, 并批量解析物料名 —— 只说「某笔转交被拒」发起方仍不知是哪件东西还在 自己手上,必须让他一眼认出来。 · ack_rejects(user_id, ids):发起方确认后写 reject_seen_at,幂等。 · GET .../transfer/pending-count 的响应并入 rejects:与待接收数量共用同一次 轮询,前端不必多打一个请求。 · POST .../transfer/reject-ack:无 permission_required,同 accept/reject。 顺带补一处同源显示缺口 ---- 流转时间线里,被拒绝的转交与成功的长得一模一样 —— 发起方翻记录时同样会 误判。现将转交状态一并带出时间线事件。 验证(15 项断言全通过) ---- 发起方收到待告知的拒绝(含物料名/接收人/拒绝原因);接收人与无关人看不到; ack 后不再提醒且幂等;ACCEPTED 不产生告知;None/非法 user_id 均安全返回; 库存零副作用、数据零残留。
2026-09-17 10:44:00 +08:00
# 仅供发起方「被拒绝」提醒使用,判断是否需要告知由 reject_seen_at 决定
'reject_seen': self.reject_seen_at is not None,
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
'reject_reason': self.reject_reason,
feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列) 背景 ---- 原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串: 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时 return_time/return_operator/return_signature 被逐次覆盖,部分归还下 「谁在什么时候还了多少」永久丢失。 本次改动(全部增量,无破坏性 DDL,可回滚) ---- 1) trans_borrow 补列 · borrower_id / current_holder_id / current_holder_name —— 身份锚点 · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库) 2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新 3) 新建 trans_borrow_return —— 归还流水,逐次记录,根治部分归还失忆症 4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user; 未归还的 32 行全部绑定 current_holder 5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms 前缀桥接把权限放大给所有持有 op_borrow:operation 的角色) 设计取舍 ---- · current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, 归还校验不会对已结清单据误触发。 · 三张表都不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。 · dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的 数据源;用申请人或借用人冒充实物交接人比留空更危险。 验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
'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,
}
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 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。
2026-09-16 15:45:11 +08:00
# =============================================================================
# 原单退回(逆向物流)
# =============================================================================
# 设计要点
# --------
# 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)
feat(return): 退回流水看板接口与权限收口 新增只读台账接口: - GET /api/v1/outbound/returns 退回流水(分页 + 关键词 + 类型 + 时间过滤) 返回 原出库单号 / 物料名称 / 规格 / SKU / 退回类型 / 退回数量 / 原因 / 操作人 / 退回时间 / 公司。出库单号经 trans_outbound 批量补齐,物料名按 多态来源批量解析,均为批量查询无 N+1。 权限收口(配合 db_migrations 里的三个权限码): - return-from-outbound inventory_stocktake:operation -> outbound_return - GET /stock/defective inventory_stocktake -> defective_list - restock inventory_stocktake:operation -> defective_restock - scrap inventory_stocktake:operation -> defective_scrap - change-status inventory_stocktake:operation -> stock_change_status 原先这四个接口搭的是「盲盘作业」权限的便车,职责错配、审计不合规。 实测 SALES(销售)角色持有 inventory_stocktake,意味着销售人员能读整份 不良品台账——与业务对台账可见性的要求不符。全部改用无冒号专用码后, 实测「只授予 inventory_stocktake:operation」对四个接口均返回 403,便车已封。 trans_return 补 company_name 快照: 退回流水的隔离判定原先只能靠 join 链推,而库存行会被入库模块物理删除 (实测 1077 条出库记录中已有 7 条悬空),链路一断记录就会对普通用户 静默消失。改由退回时落快照,隔离不再依赖任何 join。
2026-09-16 16:45:52 +08:00
# ★ 公司快照:退回发生时的所属公司。
# 不靠 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)
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 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。
2026-09-16 15:45:11 +08:00
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,
feat(return): 退回流水看板接口与权限收口 新增只读台账接口: - GET /api/v1/outbound/returns 退回流水(分页 + 关键词 + 类型 + 时间过滤) 返回 原出库单号 / 物料名称 / 规格 / SKU / 退回类型 / 退回数量 / 原因 / 操作人 / 退回时间 / 公司。出库单号经 trans_outbound 批量补齐,物料名按 多态来源批量解析,均为批量查询无 N+1。 权限收口(配合 db_migrations 里的三个权限码): - return-from-outbound inventory_stocktake:operation -> outbound_return - GET /stock/defective inventory_stocktake -> defective_list - restock inventory_stocktake:operation -> defective_restock - scrap inventory_stocktake:operation -> defective_scrap - change-status inventory_stocktake:operation -> stock_change_status 原先这四个接口搭的是「盲盘作业」权限的便车,职责错配、审计不合规。 实测 SALES(销售)角色持有 inventory_stocktake,意味着销售人员能读整份 不良品台账——与业务对台账可见性的要求不符。全部改用无冒号专用码后, 实测「只授予 inventory_stocktake:operation」对四个接口均返回 403,便车已封。 trans_return 补 company_name 快照: 退回流水的隔离判定原先只能靠 join 链推,而库存行会被入库模块物理删除 (实测 1077 条出库记录中已有 7 条悬空),链路一断记录就会对普通用户 静默消失。改由退回时落快照,隔离不再依赖任何 join。
2026-09-16 16:45:52 +08:00
'company_name': self.company_name,
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 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。
2026-09-16 15:45:11 +08:00
}
# 状态机(三期升级):
# 待处理 ──┬─→ 处理中 ──┬─→ 已回库(全部回库,无报废)
# │ ├─→ 已报废(全部报废,无回库)
# │ └─→ 已闭环(回库与报废混合,在管量归零)
# └─(一次处置即为终态时,直接跳到对应的终态)
#
# ★ 三种终态而非单一「已闭环」,是为了让工作台一眼看出这批坏件的**去向**:
# 修好回到库存了、还是被销毁了。混合处置无法用单一去向描述,才归到「已闭环」。
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,
}