feat(return): 退回、回库、报废与在管台账接口

打通逆向物流的全部后端入口。

新增接口(app/api/v1/inbound/stock.py):
- POST /stock/<id>/change-status      库存状态变更(在库/冻结/不良品)
- POST /stock/return-from-outbound    通用原单退回
- POST /stock/defective/<id>/restock  不良品修好回库(支持部分回库)
- POST /stock/defective/<id>/scrap    不良品报废销毁
- GET  /stock/defective               在管台账分页查询

设计要点:
- 良品退回加回原库存行;不良品退回则库存表分毫不动,只写独立在管台账。
  这样坏件从根上不会混进可分配池
- 全链路 Fail-Closed 守卫:良品退回到非「在库」行会被拒(status 是行级
  属性,加回已冻结/不良品的行会让良品被连带隔离);原库存行已不存在会被
  拒(入库模块会物理删除库存行,实测 1077 条出库记录中已有 7 条悬空)
- 三个写接口均加 with_for_update 行锁 + prevent_double_submit 幂等锁。
  装饰器顺序为 permission_required → prevent_double_submit,顺序颠倒会因
  JWT 未验证而抛错、被自身 except 捕获后 fail-open 降级
- 报废同时写 trans_scrap 台账(source_table 用 trans_defective_goods 并
  存台账自身主键,与 trans_borrow/trans_repair 作来源时的约定一致),
  成本按原库存行 best-effort 取价,取不到记 0 而不中断报废

报废报表集成(app/api/v1/scrap.py):
- _resolve_materials 补 trans_defective_goods 分支。物料名已在台账冗余存储,
  不联表——坏件的原库存行可能已被删除,联表取名称会得到空值
- 公司隔离补 defective_subq 分支。原先按 source_table 逐个构造子查询,
  未知来源会被整体过滤,导致这类记录对普通用户静默消失
- 无审批单号的分组前缀按来源分流:不良品直报不再套用 LEGACY-(它是新业务
  记录,不是历史脏数据)。分组键同步带上来源标记,且 _order_key_pred 的
  SQL 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
This commit is contained in:
yueli
2026-09-16 15:45:31 +08:00
parent 69c38a1bf7
commit dda6e4c787
2 changed files with 768 additions and 8 deletions

View File

@ -440,6 +440,22 @@ class ScrapService:
'batch_number': getattr(rp, 'serial_number', '') or '',
}
# 4) 不良品在管台账来源(三期):物料名与规格已冗余在台账本表,无需联表。
# ★ 之所以冗余存这几个字段:坏件的原库存行可能已被删除(入库模块会
# 物理删除库存行),联表取名称会得到空值,报表上就只剩一串 ID。
tdg_ids = {r.stock_id for r in rows
if r.source_table == 'trans_defective_goods' and r.stock_id}
if tdg_ids:
from app.models.transaction import TransDefectiveGoods
for g in TransDefectiveGoods.query.filter(
TransDefectiveGoods.id.in_(tdg_ids)).all():
resolved[('trans_defective_goods', g.id)] = {
'material_name': g.material_name or '',
'spec_model': g.spec_model or '',
'warehouse_location': '',
'batch_number': '',
}
return resolved
@staticmethod
@ -488,7 +504,7 @@ class ScrapService:
from app.utils.advanced_filter import (
build_predicate, is_negative, invert_condition,
)
from sqlalchemy import or_, and_, tuple_, func as _func
from sqlalchemy import or_, and_, tuple_, case, func as _func
parent_map = {
'no': TransScrap.scrap_request_no,
@ -506,11 +522,22 @@ class ScrapService:
TransScrap.scrap_request_no == h.scrap_request_no,
))
else:
# ★ 来源标记必须与内存分组键(query_records 里的 gkey)
# 保持一致,否则「按单筛选」的结果与页面分组会对不上:
# 同一分钟内、同一操作人的「不良品直报」与「普通直接报废」
# 在页面是两个组,在筛选里却会互相带出。
_origin_flag = case(
(TransScrap.source_table == 'trans_defective_goods', 1),
else_=0,
)
keys.append(and_(
TransScrap.scrap_request_no.is_(None),
_func.date_trunc('minute', TransScrap.operation_time)
== _func.date_trunc('minute', h.operation_time),
TransScrap.operator_name == h.operator_name,
_origin_flag == (
1 if h.source_table == 'trans_defective_goods' else 0
),
))
return or_(*keys) if keys else None
@ -621,9 +648,20 @@ class ScrapService:
).join(StockProduct, TransBorrow.stock_id == StockProduct.id).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
# ★ 不良品在管报废(三期):台账自带 company_name 快照,直接按它过滤。
# 刻意**不**联表 MaterialBase —— 那批坏件的原库存行可能已被删除,
# 联表会让这类记录从普通用户视图中整批静默消失。
from app.models.transaction import TransDefectiveGoods
defective_subq = db.session.query(TransScrap.id).join(
TransDefectiveGoods,
db.and_(TransScrap.stock_id == TransDefectiveGoods.id,
TransScrap.source_table == 'trans_defective_goods')
).filter(TransDefectiveGoods.company_name == company_limit)
all_matches = buy_subq.union(
semi_subq, product_subq, repair_subq,
borrow_stock_buy, borrow_stock_semi, borrow_stock_product
borrow_stock_buy, borrow_stock_semi, borrow_stock_product,
defective_subq
).subquery()
query = query.filter(TransScrap.id.in_(all_matches))
@ -640,11 +678,19 @@ class ScrapService:
groups = {}
for r in rows:
# ★ 不良品在管报废(source_table='trans_defective_goods')同样没有审批
# 单号,但它是**新业务**产生的记录,不是历史脏数据。沿用 LEGACY-
# 前缀会让业务人员误判为遗留数据,故单独成组并换用「不良品直报-」。
is_defective_origin = (r.source_table == 'trans_defective_goods')
if r.scrap_request_no:
gkey = ('req', r.scrap_request_no)
else:
ts = r.operation_time.strftime('%Y-%m-%d %H:%M') if r.operation_time else '未知时间'
gkey = ('legacy', ts, r.operator_name or '')
# ★ 分组键带上来源标记:不良品直报自成一类,不与普通直接报废
# 混进同一组(二者前缀不同,混组会导致标题语义不一致)。
# 注意此键必须与下方 _order_key_pred 的 SQL 谓词保持一致。
gkey = ('legacy', ts, r.operator_name or '', is_defective_origin)
g = groups.get(gkey)
if g is None:
@ -652,9 +698,10 @@ class ScrapService:
if gkey[0] == 'req':
req_no = r.scrap_request_no
else:
# 虚拟单号:无单号的历史直接报废,仍给出可读标识便于追溯
# 虚拟单号:无审批单号的直接报废,给出可读标识便于追溯
ts_raw = r.operation_time.strftime('%Y%m%d%H%M') if r.operation_time else '000000000000'
req_no = f"LEGACY-{ts_raw}-{op_name or '未知'}"
prefix = '不良品直报' if is_defective_origin else 'LEGACY'
req_no = f"{prefix}-{ts_raw}-{op_name or '未知'}"
g = {
'scrap_request_no': req_no,
'is_legacy': gkey[0] == 'legacy',