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 项断言全通过)
勾选补发 → 免审批单生成、关联退回、预占库存、原领用人入备注;
不勾选 → 不生成补发单、良品正常回库;
★ 库存不足 → 接口拒绝且**退回流水/补发单/退回额度全部未落库**(整笔回滚);
补发量 > 退回量被拒;库存与数据零残留。
This commit is contained in:
@ -2766,12 +2766,20 @@ def return_from_outbound():
|
||||
"outbound_id": 123, # 必填,trans_outbound.id(出库**明细行**,非单号)
|
||||
"return_qty": 2, # 必填,本次退回数量
|
||||
"is_defective": false, # 必填,true=不良品退回,false=良品退回
|
||||
"reason": "错领退回" # 可选
|
||||
"reason": "错领退回", # 可选
|
||||
"need_reissue": true, # 可选,退回后是否自动生成补发单
|
||||
"reissue_qty": 2 # 可选,补发数量,默认 = return_qty
|
||||
}
|
||||
|
||||
两条分支的差异:
|
||||
· 良品 → 加回原库存行的 stock_quantity 与 available_quantity
|
||||
· 不良品 → 库存表分毫不动,转 trans_defective_goods 在管台账
|
||||
|
||||
补发(need_reissue=true):
|
||||
· 自动生成一张**免审批**出库单(status=1,直接进入待执行),
|
||||
申请人 = 原出库单的申请人,并关联 source_return_id 回本笔退回;
|
||||
· 提交即预占库存(strict),**不足则整笔退回一并回滚**并返回明确提示 ——
|
||||
若只让补发静默失败,「需要补发」的意图就丢了。库管可取消勾选后重试。
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
operator_name = _normalize_user_id()
|
||||
@ -2779,6 +2787,9 @@ def return_from_outbound():
|
||||
outbound_id = data.get('outbound_id')
|
||||
is_defective = data.get('is_defective')
|
||||
reason = (data.get('reason') or '').strip() or None
|
||||
# 补发(可选):退回后申请人往往仍需这件东西。勾选则自动生成一张免审批出库单。
|
||||
need_reissue = bool(data.get('need_reissue'))
|
||||
reissue_qty = data.get('reissue_qty')
|
||||
|
||||
# ---- 1. 入参校验(脏值一律挡在入口)----
|
||||
if not outbound_id:
|
||||
@ -2882,11 +2893,87 @@ def return_from_outbound():
|
||||
if goods is not None:
|
||||
goods.return_id = ledger.id
|
||||
|
||||
# ==================================================================
|
||||
# ---- 5. 补发(可选)----
|
||||
# 退回后申请人往往**仍然需要这件东西**(尤其是坏件 —— 原需求并未
|
||||
# 被满足)。勾选即自动生成一张**免审批**的出库单并关联回本笔退回,
|
||||
# 使「退回 → 补发」形成闭环;否则现场只能靠人记住再手建一张单,
|
||||
# 而那张单与原单看不出任何关系。
|
||||
#
|
||||
# ★ 库存不足时**整笔回滚**(下面的 reserve_for_items 会抛错)。
|
||||
# 若只让补发静默失败,「需要补发」的意图就丢了 —— 那正是本功能
|
||||
# 要解决的问题。回滚后库管会看到明确提示,可取消勾选重试。
|
||||
# ==================================================================
|
||||
reissue = None
|
||||
if need_reissue:
|
||||
# 单号生成器在 OutboundApprovalService 上(不在 OutboundService)
|
||||
from app.services.outbound_service import OutboundApprovalService
|
||||
from app.services.inventory_reservation import reserve_for_items
|
||||
from app.models.outbound import OutboundApproval
|
||||
|
||||
if reissue_qty is None:
|
||||
reissue_qty = return_qty # 默认与本次退回量一致
|
||||
try:
|
||||
reissue_qty = float(reissue_qty)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('补发数量格式无效')
|
||||
if reissue_qty <= 0:
|
||||
raise ValueError('补发数量必须大于 0')
|
||||
if reissue_qty > return_qty:
|
||||
raise ValueError(
|
||||
f'补发数量({reissue_qty})不能大于本次退回数量({return_qty})'
|
||||
)
|
||||
|
||||
base = getattr(stock_row, 'base', None)
|
||||
if base is None:
|
||||
raise ValueError('原库存行的物料主数据已不存在,无法生成补发单')
|
||||
|
||||
# 提交即预占,strict=True —— 与出库申请同一口径,不足即整单失败
|
||||
reserved_items, _shortages = reserve_for_items(
|
||||
[{
|
||||
'base_id': base.id,
|
||||
'name': base.name or '',
|
||||
'spec_model': base.spec_model or '',
|
||||
'quantity': reissue_qty,
|
||||
}],
|
||||
company_limit=get_current_company_filter(),
|
||||
strict=True,
|
||||
)
|
||||
|
||||
# ★ 申请人取**当前操作人**(办理退回的库管)。
|
||||
# 为什么不是「原申请人」:trans_outbound **没有申请人字段,也没有
|
||||
# 指回原审批单的关联**(扫码出库时只把审批单状态置为 3),因此
|
||||
# 无法可靠判定原申请人是谁 —— 按 consumer_name 反查会重蹈「重名
|
||||
# 错绑」的覆辙(借用人姓名回填那轮刚踩过)。
|
||||
# 原领用人写入备注,便于人工追溯。
|
||||
_applicant = get_jwt_identity()
|
||||
try:
|
||||
_applicant = int(_applicant)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('无法确定补发单申请人:当前登录用户缺失')
|
||||
|
||||
reissue = OutboundApproval(
|
||||
request_no=OutboundApprovalService.generate_request_no(),
|
||||
applicant_id=_applicant,
|
||||
outbound_type=outbound.outbound_type,
|
||||
# 免审批:原需求已经批过一次,补发只是兑现它,重复审批是负担
|
||||
status=1,
|
||||
approved_at=beijing_time(),
|
||||
source_return_id=ledger.id,
|
||||
remark=(f'原单退回补发(原出库单 {outbound.outbound_no or outbound.id}'
|
||||
f',原领用人 {outbound.consumer_name or "未知"})'),
|
||||
)
|
||||
reissue.set_items(reserved_items)
|
||||
reissue.allowed_approvers = '[]'
|
||||
db.session.add(reissue)
|
||||
db.session.flush()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': f'退回成功,{outcome}',
|
||||
'msg': f'退回成功,{outcome}'
|
||||
+ (f';已生成补发单 {reissue.request_no}' if reissue else ''),
|
||||
'data': {
|
||||
'outbound_id': outbound.id,
|
||||
'return_id': ledger.id,
|
||||
@ -2895,6 +2982,12 @@ def return_from_outbound():
|
||||
'returned_quantity': float(outbound.returned_quantity),
|
||||
'returnable_quantity': shipped - float(outbound.returned_quantity),
|
||||
'defective_goods_id': goods.id if goods is not None else None,
|
||||
# 补发单(未勾选时为 null)
|
||||
'reissue': ({
|
||||
'id': reissue.id,
|
||||
'request_no': reissue.request_no,
|
||||
'quantity': reissue_qty,
|
||||
} if reissue else None),
|
||||
},
|
||||
}), 200
|
||||
|
||||
|
||||
@ -34,6 +34,12 @@ class OutboundApproval(db.Model):
|
||||
# 明细快照 (存储出库物品的名称、规格、库位、数量等信息,无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)
|
||||
@ -91,6 +97,9 @@ class OutboundApproval(db.Model):
|
||||
'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,
|
||||
|
||||
Reference in New Issue
Block a user