From 7071c8930546ac02732c939cc7f48675b7ce224f Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 11:56:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(outbound):=20=E5=8E=9F=E5=8D=95=E9=80=80?= =?UTF-8?q?=E5=9B=9E=E5=90=8E=E5=8F=AF=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=A1=A5=E5=8F=91=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 原单退回只做两件事:良品加回库存 / 不良品转在管台账。但**申请人的需求并没有 被满足** —— 东西交回来了(甚至还是坏的),系统却不提醒任何人、无单据承载 「我要重新领一份」,退回与后续再出库之间也毫无关联。现场只能靠人记住再手建 一张出库申请,而那张单与原单看不出任何关系。 改动 ---- · 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 项断言全通过) 勾选补发 → 免审批单生成、关联退回、预占库存、原领用人入备注; 不勾选 → 不生成补发单、良品正常回库; ★ 库存不足 → 接口拒绝且**退回流水/补发单/退回额度全部未落库**(整笔回滚); 补发量 > 退回量被拒;库存与数据零残留。 --- db_migrations/phase7_outbound_reissue.sql | 65 +++++++++++++ inventory-backend/app/api/v1/inbound/stock.py | 97 ++++++++++++++++++- inventory-backend/app/models/outbound.py | 9 ++ 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 db_migrations/phase7_outbound_reissue.sql diff --git a/db_migrations/phase7_outbound_reissue.sql b/db_migrations/phase7_outbound_reissue.sql new file mode 100644 index 0000000..18443fa --- /dev/null +++ b/db_migrations/phase7_outbound_reissue.sql @@ -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; diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 9e24a23..b516717 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -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 diff --git a/inventory-backend/app/models/outbound.py b/inventory-backend/app/models/outbound.py index 84b2739..8b4697b 100644 --- a/inventory-backend/app/models/outbound.py +++ b/inventory-backend/app/models/outbound.py @@ -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,