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:
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user