trans_outbound 原先只落了 applicant_id(申请人,phase8 加的),没有任何指回 outbound_approval 的外键。出库时 request_id 是强制必填、approval 对象也一直 在手上,但只把 applicant_id 复制过来就丢弃了 —— 于是从一条出库明细无法回答 「这是哪张申请单出的库」:单号 request_no、申请说明、明细快照 items_json 都在 申请单上,同一张单分几次扫码出库的明细也串不起来。 与 applicant_id 语义正交:applicant_id 是「人」(退回补发要挂回真正该拿东西 的人),request_id 是「那张单」(单据追溯用)。两者都由同一个 approval 带出。 存量行留 NULL 不回填 —— 与 phase8 同一个理由:历史出库与其来源审批单之间没有 任何可用关联,按单号/时间猜会重蹈「重名错绑」的覆辙。NULL 表示「产生于本列 上线之前」。 DDL 见 db_migrations/phase11_trans_outbound_request_link.sql —— 幂等 (ADD COLUMN IF NOT EXISTS),可重复执行,文件尾部自带核对 SELECT 与回滚段。 执行:docker exec -i inventory_db psql -U test -d inventory_system < 该文件
197 lines
9.3 KiB
Python
197 lines
9.3 KiB
Python
from app.extensions import db, beijing_time
|
||
from app.models.system import SysUser
|
||
from datetime import datetime
|
||
import json
|
||
|
||
|
||
class OutboundApproval(db.Model):
|
||
"""
|
||
出库审批单模型
|
||
用于管理出库申请的多级审批流程
|
||
"""
|
||
__tablename__ = 'outbound_approval'
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
# 审批单号
|
||
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||
# 申请人ID
|
||
applicant_id = db.Column(db.Integer, nullable=False, index=True)
|
||
# 申请说明
|
||
remark = db.Column(db.Text)
|
||
# 出库类型:SALES/USE/PRODUCTION/LOSS/REPAIR(申请时确定,扫码出库自动带出)
|
||
outbound_type = db.Column(db.String(50))
|
||
# 状态: 0-待审批, 1-已通过, 2-已驳回, 3-已完成(已出库)
|
||
status = db.Column(db.Integer, default=0, nullable=False)
|
||
# 允许审批的人员列表 (JSON格式: [{"type": "role", "value": "admin"}, {"type": "user", "value": "123"}])
|
||
allowed_approvers = db.Column(db.Text)
|
||
# 实际审批人ID (多人审批时记录第一个通过的)
|
||
actual_approver_id = db.Column(db.Integer, index=True)
|
||
# 审批时间
|
||
approved_at = db.Column(db.DateTime)
|
||
# 驳回原因
|
||
reject_reason = db.Column(db.Text)
|
||
|
||
# 明细快照 (存储出库物品的名称、规格、库位、数量等信息,无SKU字段)
|
||
items_json = db.Column(db.Text)
|
||
|
||
# ★ 补发单来源:trans_return.id,非空即表示本单由「原单退回」自动生成。
|
||
# 不加 is_reissue 布尔列 —— 「是不是补发」完全由来源是否存在决定,
|
||
# 再加一列就是同一事实的两处存储,必然有不同步的一天。
|
||
# 也不冗余存原出库单 ID:trans_return 已有 outbound_id,一跳即可。
|
||
source_return_id = db.Column(db.Integer, index=True)
|
||
|
||
# 创建时间和更新时间
|
||
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
|
||
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
|
||
|
||
def _safe_parse_json(self, value):
|
||
"""
|
||
安全解析 JSON 字段:
|
||
- 如果 value 已是 list/dict,直接返回
|
||
- 如果是 str,尝试 json.loads()
|
||
- 解析失败或为 None/空,均返回 []
|
||
"""
|
||
if value is None:
|
||
return []
|
||
if isinstance(value, (list, dict)):
|
||
return value
|
||
if isinstance(value, str):
|
||
val = value.strip()
|
||
if not val:
|
||
return []
|
||
try:
|
||
parsed = json.loads(val)
|
||
return parsed if isinstance(parsed, list) else []
|
||
except (json.JSONDecodeError, TypeError, ValueError):
|
||
return []
|
||
return []
|
||
|
||
def get_items(self):
|
||
"""解析 items_json,返回物品列表"""
|
||
return self._safe_parse_json(self.items_json)
|
||
|
||
def set_items(self, items):
|
||
"""设置 items_json"""
|
||
self.items_json = json.dumps(items, ensure_ascii=False) if items else '[]'
|
||
|
||
def get_allowed_approvers(self):
|
||
"""解析 allowed_approvers,返回审批人列表"""
|
||
return self._safe_parse_json(self.allowed_approvers)
|
||
|
||
def set_allowed_approvers(self, approvers):
|
||
"""设置 allowed_approvers"""
|
||
self.allowed_approvers = json.dumps(approvers, ensure_ascii=False) if approvers else '[]'
|
||
|
||
def to_dict(self):
|
||
return {
|
||
'id': self.id,
|
||
'request_no': self.request_no,
|
||
'applicant_id': self.applicant_id,
|
||
'applicant_name': self._get_user_name(self.applicant_id),
|
||
'remark': self.remark,
|
||
'outbound_type': self.outbound_type or '',
|
||
'status': self.status,
|
||
'status_text': ['待审批', '已通过', '已驳回', '已完成', '已完结'][self.status] if self.status in [0, 1, 2, 3, 4] else '未知',
|
||
'allowed_approvers': self.get_allowed_approvers(),
|
||
'actual_approver_id': self.actual_approver_id,
|
||
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
|
||
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
|
||
'reject_reason': self.reject_reason,
|
||
# 补发标识:前端据此打「补发」标签
|
||
'source_return_id': self.source_return_id,
|
||
'is_reissue': self.source_return_id is not None,
|
||
'items': self.get_items(),
|
||
'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,
|
||
}
|
||
|
||
def _get_user_name(self, user_id):
|
||
"""根据用户ID获取用户名"""
|
||
if not user_id:
|
||
return ""
|
||
|
||
from app.models.system import SysUser
|
||
try:
|
||
# ★ 必须用 .get() 按主键 ID 查询,千万不能用 username=user_id 去查
|
||
user = SysUser.query.get(user_id)
|
||
return user.username if user else f"未知用户({user_id})"
|
||
except Exception as e:
|
||
return f"用户({user_id})"
|
||
|
||
|
||
class TransOutbound(db.Model):
|
||
__tablename__ = 'trans_outbound'
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
# 修改:不再唯一,因为批量出库时多个商品共用一个单号
|
||
outbound_no = db.Column(db.String(100), nullable=False)
|
||
|
||
# 关联源库存信息
|
||
sku = db.Column(db.String(100))
|
||
source_table = db.Column(db.String(50)) # 'stock_buy', 'stock_product', 'stock_semi'
|
||
stock_id = db.Column(db.Integer) # 对应源表的主键ID
|
||
barcode = db.Column(db.String(100)) # 实际扫码内容
|
||
|
||
# 业务信息
|
||
outbound_type = db.Column(db.String(50), default='SALES') # SALES(销售), USE(领用), PRODUCTION(生产)
|
||
quantity = db.Column(db.Numeric(19, 4), nullable=False)
|
||
|
||
# [新增] 出库时的单价,用于计算金额
|
||
unit_price = db.Column(db.Numeric(19, 2), default=0)
|
||
|
||
# 签字与追溯
|
||
consumer_name = db.Column(db.String(100)) # 领用人/客户
|
||
# ★ 申请人ID:创建出库时从**关联审批单**带出(request_id 已强制必填,
|
||
# approval 恒非 None)。为什么需要它:退回后勾选补发时,补发单要挂回
|
||
# 「真正该拿东西的人」名下;而 consumer_name 是扫码时自由填写的领用人/
|
||
# 客户名,既不可靠也可能不是本系统用户,按姓名反查会重名错绑。
|
||
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与其来源审批单之间没有任何可用
|
||
# 关联,无从回填;退回时由库管在选择器里明确指定。
|
||
applicant_id = db.Column(db.Integer, index=True)
|
||
# ★ 来源申请单ID:创建出库时从**关联审批单**带出(出库时 request_id 已强制
|
||
# 必填,approval 恒非 None)。与上面的 applicant_id 语义正交 ——
|
||
# applicant_id 是「人」,本列是「那张单」。
|
||
# 为什么要它:原先只落了 applicant_id,从一条出库明细反查不到它属于哪张
|
||
# 申请单(单号 request_no、申请说明、明细快照 items_json 都在申请单上)。
|
||
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与来源审批单之间没有任何可用关联,
|
||
# 无从回填。
|
||
# DDL 见 db_migrations/phase11_trans_outbound_request_link.sql
|
||
request_id = db.Column(db.Integer, index=True)
|
||
signature_path = db.Column(db.Text) # 电子签名图片路径
|
||
outbound_time = db.Column(db.DateTime, default=beijing_time)
|
||
operator_name = db.Column(db.String(100)) # 操作员
|
||
|
||
# [新增] 出库时的库位快照(从源库存记录带出,便于历史追溯)
|
||
warehouse_location = db.Column(db.String(100))
|
||
|
||
# [新增] 累计已退回数量(良品 + 不良品口径合并),用于原单退回的额度校验。
|
||
# ★ 用 numeric(19,4) 而非 float:本系统所有数量列一律 numeric(19,4),
|
||
# 且该值要参与 `return_qty <= quantity - returned_quantity` 的判等比较,
|
||
# 浮点误差会让反复部分退回后出现「已退满却判定未退满」的错判。
|
||
# DDL 见 db_migrations/phase2_return_and_defective_goods.sql
|
||
returned_quantity = db.Column(db.Numeric(19, 4), nullable=False, default=0)
|
||
|
||
remark = db.Column(db.Text)
|
||
|
||
def to_dict(self):
|
||
qty = float(self.quantity) if self.quantity else 0
|
||
returned = float(self.returned_quantity) if self.returned_quantity is not None else 0
|
||
return {
|
||
'id': self.id,
|
||
'outbound_no': self.outbound_no,
|
||
'sku': self.sku,
|
||
'source_table': self.source_table,
|
||
'outbound_type': self.outbound_type,
|
||
'quantity': qty,
|
||
# [新增] 退回额度三件套,供前端判断该明细还能退多少
|
||
'returned_quantity': returned,
|
||
'returnable_quantity': qty - returned,
|
||
'unit_price': float(self.unit_price) if self.unit_price else 0,
|
||
'consumer_name': self.consumer_name,
|
||
'request_id': self.request_id,
|
||
'signature_path': self.signature_path,
|
||
'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None,
|
||
'operator_name': self.operator_name,
|
||
'warehouse_location': self.warehouse_location or '',
|
||
'remark': self.remark
|
||
} |