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

187 lines
8.6 KiB
Python
Raw Normal View History

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)
feat(outbound): 原单退回后可自动生成补发单 背景 ---- 原单退回只做两件事:良品加回库存 / 不良品转在管台账。但**申请人的需求并没有 被满足** —— 东西交回来了(甚至还是坏的),系统却不提醒任何人、无单据承载 「我要重新领一份」,退回与后续再出库之间也毫无关联。现场只能靠人记住再手建 一张出库申请,而那张单与原单看不出任何关系。 改动 ---- · outbound_approval 新增 source_return_id(非空 = 补发单),把「退回 → 补发」 串成闭环。 · POST /inbound/stock/return-from-outbound 新增 need_reissue / reissue_qty: 勾选即自动生成一张**免审批**出库单(status=1,直接进入待执行), 沿用原单出库类型,并关联回本笔退回。 ★ 为什么只存退回单 ID,不加 is_reissue 布尔列 「是不是补发」完全由来源是否存在决定,再加一列就是同一事实的两处存储, 必然有不同步的一天。也不冗余存原出库单 ID:trans_return 已有 outbound_id。 ★ 库存不足 → 整笔回滚(关键取舍) 补发走 reserve_for_items(strict=True),与出库申请同一口径。不足时抛错, 退回也一并回滚 —— 若只让补发静默失败,「需要补发」的意图就丢了, 那正是本功能要解决的问题。库管看到提示后取消勾选即可只做退回过账。 ★ 一个被发现的数据约束(改变了原设计) 原打算把补发单的申请人设为「原出库单的申请人」,但 **trans_outbound 既没有 申请人字段,也没有指回原审批单的关联**(扫码出库时只把审批单状态置为 3)。 按 consumer_name 反查会重蹈「重名错绑」的覆辙。故申请人取**当前操作人**, 原领用人写入备注供人工追溯。 ⚠ 若业务要求补发单挂在原领用人名下,需要前端在退回弹窗里加一个「补发给谁」 的人员选择 —— 请确认是否需要。 验证(打桩 JWT 直连真实接口,22 项断言全通过) 勾选补发 → 免审批单生成、关联退回、预占库存、原领用人入备注; 不勾选 → 不生成补发单、良品正常回库; ★ 库存不足 → 接口拒绝且**退回流水/补发单/退回额度全部未落库**(整笔回滚); 补发量 > 退回量被拒;库存与数据零残留。
2026-09-17 11:56:38 +08:00
# ★ 补发单来源: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,
feat(outbound): 原单退回后可自动生成补发单 背景 ---- 原单退回只做两件事:良品加回库存 / 不良品转在管台账。但**申请人的需求并没有 被满足** —— 东西交回来了(甚至还是坏的),系统却不提醒任何人、无单据承载 「我要重新领一份」,退回与后续再出库之间也毫无关联。现场只能靠人记住再手建 一张出库申请,而那张单与原单看不出任何关系。 改动 ---- · outbound_approval 新增 source_return_id(非空 = 补发单),把「退回 → 补发」 串成闭环。 · POST /inbound/stock/return-from-outbound 新增 need_reissue / reissue_qty: 勾选即自动生成一张**免审批**出库单(status=1,直接进入待执行), 沿用原单出库类型,并关联回本笔退回。 ★ 为什么只存退回单 ID,不加 is_reissue 布尔列 「是不是补发」完全由来源是否存在决定,再加一列就是同一事实的两处存储, 必然有不同步的一天。也不冗余存原出库单 ID:trans_return 已有 outbound_id。 ★ 库存不足 → 整笔回滚(关键取舍) 补发走 reserve_for_items(strict=True),与出库申请同一口径。不足时抛错, 退回也一并回滚 —— 若只让补发静默失败,「需要补发」的意图就丢了, 那正是本功能要解决的问题。库管看到提示后取消勾选即可只做退回过账。 ★ 一个被发现的数据约束(改变了原设计) 原打算把补发单的申请人设为「原出库单的申请人」,但 **trans_outbound 既没有 申请人字段,也没有指回原审批单的关联**(扫码出库时只把审批单状态置为 3)。 按 consumer_name 反查会重蹈「重名错绑」的覆辙。故申请人取**当前操作人**, 原领用人写入备注供人工追溯。 ⚠ 若业务要求补发单挂在原领用人名下,需要前端在退回弹窗里加一个「补发给谁」 的人员选择 —— 请确认是否需要。 验证(打桩 JWT 直连真实接口,22 项断言全通过) 勾选补发 → 免审批单生成、关联退回、预占库存、原领用人入备注; 不勾选 → 不生成补发单、良品正常回库; ★ 库存不足 → 接口拒绝且**退回流水/补发单/退回额度全部未落库**(整笔回滚); 补发量 > 退回量被拒;库存与数据零残留。
2026-09-17 11:56:38 +08:00
# 补发标识:前端据此打「补发」标签
'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)) # 领用人/客户
fix(outbound): 补发申请人取原申请人,绝不再回退为库管 问题 ---- 上一版在库管未指定「补发给谁」时,把补发单申请人回退成了**当前操作人(库管)**。 但补发是**原申请人的需求**,挂到库管名下逻辑不通 —— 那张单会出现在库管的 「我的申请」里,而真正该拿东西的人什么也看不到。 根因 ---- trans_outbound 只有 consumer_name(**扫码时前端自由填写**的领用人/客户名, 既不可靠也可能是外部客户),没有任何指回原审批单的关联,所以当时只能退而求其次。 根治 ---- 一、trans_outbound 新增 applicant_id,**创建出库时从关联审批单带出** (request_id 已强制必填、approval 恒非 None,故新单据必然有值)。 落这一列后,「退回 → 补发」即可自动找回真正的原申请人。 ⚠ 存量行为 NULL —— 存量出库与其来源审批单之间没有任何可用关联,无从回填。 刻意留 NULL 而不是按姓名猜(consumer_name 是自由文本,会重名错绑), 与 dispatch_operator 同一取舍:宁可留空,也不猜。 二、退回接口的申请人优先级改为: ① 前端显式指定 reissue_applicant_id ② 原出库明细记录的 applicant_id(真实原申请人) ③ 都没有 → **报错要求指定** ★ 彻底移除「回退为当前操作人」—— 那正是本次要修的逻辑错误。 三、出库列表明细返回 applicant_id,供前端精确预填。 验证(9 项断言全通过) · 新单据 → 申请人 = 原申请人(12),绝不是库管(7) · 显式指定优先于原申请人 ★ 历史单据未指定 → 接口拒绝、要求选择「补发给谁」、整笔回滚、 且**未生成任何挂在库管名下的补发单** · 历史单据 + 显式指定 → 正常 库存与数据零残留。
2026-09-17 12:07:21 +08:00
# ★ 申请人ID:创建出库时从**关联审批单**带出(request_id 已强制必填,
# approval 恒非 None)。为什么需要它:退回后勾选补发时,补发单要挂回
# 「真正该拿东西的人」名下;而 consumer_name 是扫码时自由填写的领用人/
# 客户名,既不可靠也可能不是本系统用户,按姓名反查会重名错绑。
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与其来源审批单之间没有任何可用
# 关联,无从回填;退回时由库管在选择器里明确指定。
applicant_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))
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
# [新增] 累计已退回数量(良品 + 不良品口径合并),用于原单退回的额度校验。
# ★ 用 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):
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
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,
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
'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,
'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
}