Files
KCGL/inventory-backend/app/models/transaction.py
yueli 9eb4792d4a 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

425 lines
18 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)
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)
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_name': self.borrower_name,
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
'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
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,
}