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:
65
db_migrations/phase7_outbound_reissue.sql
Normal file
65
db_migrations/phase7_outbound_reissue.sql
Normal file
@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- 出库 · 原单退回后的「补发单」关联
|
||||
--
|
||||
-- 背景
|
||||
-- 原单退回目前只做两件事:良品加回库存 / 不良品转在管台账。
|
||||
-- 但**申请人的需求并没有被满足** —— 东西交回来了(甚至还是坏的),
|
||||
-- 系统却:不提醒任何人、无单据承载「我要重新领一份」、退回与后续再出库
|
||||
-- 之间毫无关联。现场只能靠人记住再手动新建一张出库申请,且那张单与原单
|
||||
-- 看不出任何关系。
|
||||
--
|
||||
-- 本次改动
|
||||
-- outbound_approval 新增 source_return_id:非空即表示这是一张由「原单退回」
|
||||
-- 自动生成的**补发单**,从而把「退回 → 补发」串成闭环,出库记录里也能
|
||||
-- 一眼看出哪张单是补发的。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ★ 为什么不加 is_reissue 布尔列
|
||||
-- 「是不是补发」完全由「来源退回单是否存在」决定,再加一个布尔列就是
|
||||
-- 同一事实的两处存储,必然有不同步的一天(改了 A 忘了 B)。
|
||||
-- 单列既表达事实,又能直接 join 回原单。
|
||||
--
|
||||
-- ★ 为什么只存退回单 ID,不另存原出库单 ID
|
||||
-- trans_return 本身已有 outbound_id,顺着 source_return_id 一跳即可拿到
|
||||
-- 原出库明细。冗余存储只会带来「两张单对不上」的风险。
|
||||
--
|
||||
-- 幂等:带 IF NOT EXISTS,可重复执行。不含 psql 元命令,DataGrip 可直接执行。
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE outbound_approval
|
||||
ADD COLUMN IF NOT EXISTS source_return_id integer;
|
||||
|
||||
COMMENT ON COLUMN outbound_approval.source_return_id IS
|
||||
'补发单来源:trans_return.id。非空即表示本单是由「原单退回」自动生成的补发单';
|
||||
|
||||
-- 支撑「这批补发是从哪张退回单来的」与列表标识
|
||||
CREATE INDEX IF NOT EXISTS ix_outbound_approval_source_return
|
||||
ON outbound_approval (source_return_id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
SELECT '=== 1) 新列已就位 ===' AS "核对项";
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'outbound_approval' AND column_name = 'source_return_id';
|
||||
|
||||
SELECT '=== 2) 索引已就位 ===' AS "核对项";
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'outbound_approval' AND indexname = 'ix_outbound_approval_source_return';
|
||||
|
||||
SELECT '=== 3) 存量补发单(新功能上线前应为 0)===' AS "核对项";
|
||||
SELECT count(*) AS 补发单数 FROM outbound_approval WHERE source_return_id IS NOT NULL;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DROP INDEX IF EXISTS ix_outbound_approval_source_return;
|
||||
-- ALTER TABLE outbound_approval DROP COLUMN IF EXISTS source_return_id;
|
||||
-- COMMIT;
|
||||
@ -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