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

@ -2,7 +2,7 @@ from flask import Blueprint, jsonify, request, send_file, current_app
from app.extensions import db, beijing_time
from datetime import datetime, timedelta
from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity
from app.utils.decorators import permission_required, get_current_company_filter
from app.utils.decorators import permission_required, get_current_company_filter, prevent_double_submit
from sqlalchemy.orm import joinedload
import uuid as uuid_module
import io
@ -22,9 +22,29 @@ from app.models.inbound.stocktake import (
STOCKTAKE_STATUS_ACTIVE,
STOCKTAKE_STATUS_FINISHED,
)
from app.models.transaction import TransBorrow
from app.models.transaction import (
TransBorrow,
TransReturn,
TransScrap,
TransDefectiveGoods,
RETURN_TYPE_GOOD,
RETURN_TYPE_DEFECTIVE,
DEFECTIVE_STATUS_PENDING,
DEFECTIVE_STATUS_IN_PROGRESS,
RESTOCKABLE_DEFECTIVE_STATUSES,
SCRAPPABLE_DEFECTIVE_STATUSES,
VALID_DEFECTIVE_STATUSES,
defective_close_status,
)
from app.models.outbound import TransOutbound
from app.models.base import MaterialBase
# 库存状态语义的单一事实来源(与分配器共用同一套常量,避免两处定义漂移)
from app.services.inventory_reservation import (
VALID_STOCK_STATUSES,
STOCK_STATUS_IN_STOCK,
)
# 尝试导入用户模型
try:
from app.models.system import SysUser
@ -2427,9 +2447,702 @@ def update_stocktake_quantity():
db.session.commit()
return jsonify({'code': 200, 'msg': '更新成功'}), 200
except Exception as e:
db.session.rollback()
import traceback
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'更新失败: {str(e)}'}), 500
# ==============================================================================
# 库存状态变更接口(逆向物流的基础能力)
# ==============================================================================
# ★ 为什么需要它
# 分配器已引入「仅 status='在库' 方能被分配」的硬隔离
# (见 app/services/inventory_reservation.py::allocatable_filter
# 但在此之前,**全系统没有任何入口**能改写 stock 行的 status —— 状态只能靠
# 手工改库,于是「坏件退回 / 送修 / 冻结」在系统里没有落点,逆向物流无从谈起。
# 本接口补上这一环。
#
# ★ 阶段二接入预告
# 退回 / 送修 / 报废流程落地时,应**复用本接口背后的同一条变更路径**
# (而不是各自复制一份赋值逻辑),以保证「什么状态算可出货」在全系统
# 只有一处定义。届时可考虑抽成 service 层函数,本路由只做鉴权与解析。
#
# ★ 审计留痕
# stock_buy / stock_semi / stock_product 均在 audit_listener 的白名单内
# app/core/audit_listener.py:44-48因此 status / quality_status 的
# 变更会由 SQLAlchemy 事件监听器**自动**写入 audit_logs记录操作人
# (取自 JWT、IP、变更前后的值 —— 本接口刻意不手工记账,避免双写。
# 注意:监听器要求 HTTP 请求上下文,故状态变更必须在请求内直接落库,
# 不可丢给后台任务,否则会静默失去审计痕迹。
@bp.route('/<int:stock_id>/change-status', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def change_stock_status(stock_id):
"""
变更单条库存行的状态(在库 / 冻结 / 不良品)。
Body(JSON):
{
"source_table": "stock_buy" | "stock_semi" | "stock_product", # 必填
"status": "在库" | "冻结" | "不良品", # 必填
"quality_status": "合格" | "不合格" | "待检" # 可选
}
典型用法:
· 发现坏件 → status='不良品',从此不再被分配出货
· 争议/盘点待查 → status='冻结'
· 维修完成放回池子 → status='在库'
"""
data = request.get_json(silent=True) or {}
source_table = (data.get('source_table') or '').strip()
new_status = (data.get('status') or '').strip()
quality_status = data.get('quality_status')
# ---- 1. 参数校验(脏值一律挡在入口,不让它进库)----
if not source_table or not new_status:
return jsonify({'code': 400, 'msg': 'source_table 与 status 均为必填'}), 400
model = get_stock_model(source_table)
if model is None:
return jsonify({
'code': 400,
'msg': f'不支持的 source_table: {source_table}'
f'仅支持 stock_buy / stock_semi / stock_product',
}), 400
if new_status not in VALID_STOCK_STATUSES:
return jsonify({
'code': 400,
'msg': f'不支持的 status: {new_status}'
f'仅支持 {"".join(VALID_STOCK_STATUSES)}',
}), 400
try:
# ---- 2. 行锁 + 取行 ----
# ★ with_for_update 是必须的:本接口会与出库/借库的预占、报废的扣减
# 并发。不加锁的话「冻结」可能与「扣减」交错 —— 冻完之后该行仍被
# 扣走并发货,冻结形同虚设。
row = model.query.with_for_update().get(stock_id)
if not row:
return jsonify({
'code': 404,
'msg': f'库存记录不存在: {source_table}#{stock_id}',
}), 404
# ---- 3. 多租户隔离:非跨域用户只能动本公司的库存 ----
# 与分配器的口径一致(分配器按 MaterialBase.company_name 过滤候选行),
# 否则普通用户可越权冻结他司库存,等于一种拒绝服务。
company_limit = get_current_company_filter()
if company_limit is not None:
base = row.base
if (company_limit == '__NO_COMPANY__' or base is None
or (base.company_name or '') != company_limit):
return jsonify({'code': 403, 'msg': '无权操作其他公司的库存'}), 403
# ---- 4. 质量列:按表实际拥有的列写入,缺列时明确报错而非静默丢弃 ----
# ★ 实测 stock_buy 没有 quality_status 列(只有 inspection_status
# 静默忽略会让调用方以为写成功了。
if quality_status is not None:
if not hasattr(row, 'quality_status'):
return jsonify({
'code': 400,
'msg': f'{source_table} 没有 quality_status 字段;'
f'采购件的检验状态请通过入库单的 inspection_status 维护',
}), 400
row.quality_status = quality_status
old_status = row.status
old_quality = getattr(row, 'quality_status', None)
row.status = new_status
# 提交后由 audit_listener 自动记录本次变更(含操作人与前后值)
db.session.commit()
return jsonify({
'code': 200,
'msg': '状态变更成功',
'data': {
'source_table': source_table,
'stock_id': stock_id,
'sku': row.sku,
'old_status': old_status,
'status': row.status,
'old_quality_status': old_quality,
'quality_status': getattr(row, 'quality_status', None),
},
}), 200
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'状态变更失败: {str(e)}'}), 500
# ==============================================================================
# 原单退回 & 不良品回库(逆向物流二期)
# ==============================================================================
# 架构要点(详见 app/models/transaction.py 的模块注释与
# db_migrations/phase2_return_and_defective_goods.sql
#
# · 良品退回 → 加回原库存行stock_quantity 与 available_quantity 同步加回,
# 与出库时 restore_then_deduct 的扣减口径严格对称)
# · 不良品退回 → **完全不动库存表**,转入独立的 trans_defective_goods 在管
# 台账;修好后按 remaining_qty 部分/整批回库
#
# 为什么坏件不进库存表status 是**行级**属性,质量是**件级**属性。把坏件
# 加回原行只能整行打不良,而实测 stock_buy 单行最大 4789 件、中位 8 件 ——
# 退 1 件坏件会让整行良品一起被隔离,是静默的大规模库存损失。
def _lock_source_stock_row(source_table, stock_id):
"""
解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。
三条 Fail-Closed 规则:
1. source_table 必须是三张库存表之一 —— 维修单等非库存来源没有可退回的行;
2. 库存行必须仍然存在 —— 入库模块会物理删除库存行(见
buy/semi/product_service 的 db.session.delete(stock)),实测 1077 条
出库记录中已有 7 条指向不存在的行;
3. 调用方拿到行后还需自行做公司隔离与状态校验(见 _assert_company_owns
★ 为什么必须加锁:本行随后会被加减数量,且与出库/报废/状态变更并发。
不加锁会出现「读-改-写」丢失更新lost update
"""
model = get_stock_model(source_table)
if model is None:
raise ValueError(
f'来源「{source_table or "(空)"}」不支持退回,'
f'仅支持 stock_buy / stock_semi / stock_product'
)
row = model.query.with_for_update().get(stock_id) if stock_id else None
if not row:
raise ValueError(
f'原库存行已不存在({source_table}#{stock_id}),无法自动退回,'
f'请改走入库流程手工登记这批实物'
)
return row
def _assert_company_owns(row):
"""
行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。
口径与扫码出库OutboundService.get_stock_by_barcode、状态变更接口完全一致
—— 都走 MaterialBase.company_name避免三处隔离逻辑分叉。
"""
company_limit = get_current_company_filter()
if company_limit is None:
return
base = getattr(row, 'base', None)
if (company_limit == '__NO_COMPANY__' or base is None
or (base.company_name or '') != company_limit):
raise PermissionError('无权操作其他公司的库存')
@bp.route('/defective', methods=['GET'])
@permission_required('inventory_stocktake')
def list_defective_goods():
"""
不良品在管台账分页查询(供「不良品在管台账」看板页使用)。
Query:
page 页码,默认 1
page_size 每页条数,默认 20
status 状态精确过滤(待处理/处理中/已回库/已报废/已闭环);'全部' 或空 = 不过滤
keyword 模糊匹配 物料名称 / SKU / 规格型号
start_date / end_date 按退回时间created_at过滤
行级隔离:本表自带 company_name 快照,直接按它过滤。刻意不联表
MaterialBase —— 坏件的原库存行可能已被删除,联表会让记录整批消失。
"""
page = request.args.get('page', 1, type=int) or 1
page_size = request.args.get('page_size', 20, type=int) or 20
page_size = min(max(page_size, 1), 200) # 防超大分页拖垮库
status = (request.args.get('status') or '').strip()
keyword = (request.args.get('keyword') or '').strip()
start_date = (request.args.get('start_date') or '').strip()
end_date = (request.args.get('end_date') or '').strip()
try:
query = TransDefectiveGoods.query
# 行级隔离(超管/跨域 company_limit 为 None不受限
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(TransDefectiveGoods.company_name == company_limit)
if status and status not in ('全部', 'all'):
if status not in VALID_DEFECTIVE_STATUSES:
return jsonify({
'code': 400,
'msg': f'不支持的状态:{status}'
f'仅支持 {"".join(VALID_DEFECTIVE_STATUSES)}',
}), 400
query = query.filter(TransDefectiveGoods.status == status)
if keyword:
like = f'%{keyword}%'
query = query.filter(db.or_(
TransDefectiveGoods.material_name.ilike(like),
TransDefectiveGoods.sku.ilike(like),
TransDefectiveGoods.spec_model.ilike(like),
))
# 日期边界补全时分秒,避免 10 位日期被当成零点截断(与全系统口径一致)
if start_date and len(start_date) == 10:
start_date = f'{start_date} 00:00:00'
if end_date and len(end_date) == 10:
end_date = f'{end_date} 23:59:59'
if start_date:
query = query.filter(TransDefectiveGoods.created_at >= start_date)
if end_date:
query = query.filter(TransDefectiveGoods.created_at <= end_date)
# 默认按退回时间倒序:最新的坏件最需要处理
query = query.order_by(TransDefectiveGoods.created_at.desc(),
TransDefectiveGoods.id.desc())
pg = query.paginate(page=page, per_page=page_size, error_out=False)
# 汇总卡:在管总量(剩余待处理合计),供看板顶部展示
pending_total = db.session.query(
db.func.coalesce(db.func.sum(TransDefectiveGoods.remaining_qty), 0)
)
if company_limit is not None:
pending_total = pending_total.filter(
TransDefectiveGoods.company_name == company_limit)
return jsonify({
'code': 200,
'msg': 'success',
'data': {
'list': [g.to_dict() for g in pg.items],
'total': pg.total,
'page': page,
'page_size': page_size,
'pending_total': float(pending_total.scalar() or 0),
},
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'查询失败: {str(e)}'}), 500
def _defective_unit_cost(goods):
"""
取坏件单价,用于报废台账的 cost_at_scrap / total_lossbest-effort
取价口径与既有报废模块一致:成品取 sale_price采购件取 pre_tax_unit_price
半成品无价(返回 0
★ 原库存行可能已被删除(入库模块会物理删除库存行),故取不到时返回 0 ——
与借库转报废TransService.scrap_borrow 里 cost_at_scrap=0/total_loss=0
口径一致。刻意**不**因缺行而中断报废:实物已经销毁,台账必须先记上,
成本缺失是可接受的降级,记录丢失不是。
"""
model = get_stock_model(goods.source_table)
if model is None or not goods.stock_id:
return 0.0
row = model.query.get(goods.stock_id)
if not row:
return 0.0
if goods.source_table == 'stock_product':
return float(getattr(row, 'sale_price', 0) or 0)
if goods.source_table == 'stock_buy':
return float(getattr(row, 'pre_tax_unit_price', 0) or 0)
return 0.0
@bp.route('/return-from-outbound', methods=['POST'])
@permission_required('inventory_stocktake:operation')
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods
@prevent_double_submit(lock_timeout=5)
def return_from_outbound():
"""
通用原单退回。
Body(JSON):
{
"outbound_id": 123, # 必填trans_outbound.id出库**明细行**,非单号)
"return_qty": 2, # 必填,本次退回数量
"is_defective": false, # 必填true=不良品退回false=良品退回
"reason": "错领退回" # 可选
}
两条分支的差异:
· 良品 → 加回原库存行的 stock_quantity 与 available_quantity
· 不良品 → 库存表分毫不动,转 trans_defective_goods 在管台账
"""
data = request.get_json(silent=True) or {}
operator_name = _normalize_user_id()
outbound_id = data.get('outbound_id')
is_defective = data.get('is_defective')
reason = (data.get('reason') or '').strip() or None
# ---- 1. 入参校验(脏值一律挡在入口)----
if not outbound_id:
return jsonify({'code': 400, 'msg': 'outbound_id 为必填'}), 400
if is_defective is None:
return jsonify({
'code': 400,
'msg': 'is_defective 为必填true=不良品退回false=良品退回)',
}), 400
is_defective = bool(is_defective)
try:
return_qty = float(data.get('return_qty') or 0)
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': 'return_qty 无效'}), 400
if return_qty <= 0:
return jsonify({'code': 400, 'msg': '退回数量必须大于 0'}), 400
try:
# ---- 2. 锁定原出库明细并校验退回额度 ----
# ★ 行锁不可省:并发两笔退回若各自读到相同的 returned_quantity会双双
# 通过额度校验,合计退回量超过出库量 —— 凭空多出库存。
outbound = TransOutbound.query.with_for_update().get(outbound_id)
if not outbound:
raise ValueError(f'出库记录不存在ID: {outbound_id}')
shipped = float(outbound.quantity or 0)
returned = float(outbound.returned_quantity or 0)
returnable = shipped - returned
if return_qty > returnable:
raise ValueError(
f'退回数量({return_qty})超出可退额度({returnable})'
f'原出库 {shipped},已退回 {returned}'
)
# ---- 3. 锁定原库存行 + 多租户隔离 ----
stock_row = _lock_source_stock_row(outbound.source_table, outbound.stock_id)
_assert_company_owns(stock_row)
goods = None
if is_defective:
# ================= 不良品分支 =================
# ★ 原库存表**分毫不动**:坏件全程存放于独立在管台账,既不占用库存
# 数量、也不改库存行 status从根上杜绝「坏件混进可分配池」。
base = getattr(stock_row, 'base', None)
goods = TransDefectiveGoods(
outbound_id=outbound.id,
source_table=outbound.source_table,
stock_id=outbound.stock_id,
base_id=getattr(stock_row, 'base_id', None),
sku=getattr(stock_row, 'sku', '') or '',
material_name=(base.name if base else '') or '',
spec_model=(base.spec_model if base else '') or '',
quantity=return_qty,
remaining_qty=return_qty,
status=DEFECTIVE_STATUS_PENDING,
company_name=(base.company_name if base else '') or '',
reason=reason,
operator=operator_name,
)
db.session.add(goods)
outcome = '不良品已转入在管台账'
else:
# ================= 良品分支 =================
# ★ 状态防呆:把良品加回一个已冻结/不良品的行,会让良品被该行的状态
# 连带隔离status 是行级属性)—— 静默造成良品不可用。宁可报错让
# 人先决定该行的归属。
current = (stock_row.status or '').strip()
if current != STOCK_STATUS_IN_STOCK:
raise ValueError(
f'原库存行当前状态为「{current or "未设置"}」,'
f'良品退回要求该行处于「{STOCK_STATUS_IN_STOCK}」状态'
)
stock_row.stock_quantity = float(stock_row.stock_quantity or 0) + return_qty
stock_row.available_quantity = float(stock_row.available_quantity or 0) + return_qty
outcome = '良品已加回原库存'
# ---- 4. 累加退回额度 + 写退回流水 ----
outbound.returned_quantity = returned + return_qty
ledger = TransReturn(
outbound_id=outbound.id,
stock_id=outbound.stock_id,
source_table=outbound.source_table,
sku=outbound.sku,
return_qty=return_qty,
return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD,
reason=reason,
operator=operator_name,
)
db.session.add(ledger)
db.session.flush() # 先拿到 ledger.id供在管台账回填
# 在管台账回填来源流水 id形成「出库 → 退回流水 → 在管台账」的追溯闭环
if goods is not None:
goods.return_id = ledger.id
db.session.commit()
return jsonify({
'code': 200,
'msg': f'退回成功,{outcome}',
'data': {
'outbound_id': outbound.id,
'return_id': ledger.id,
'return_type': ledger.return_type,
'return_qty': return_qty,
'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,
},
}), 200
except PermissionError as e:
db.session.rollback()
return jsonify({'code': 403, 'msg': str(e)}), 403
except ValueError as e:
db.session.rollback()
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'退回失败: {str(e)}'}), 500
@bp.route('/defective/<int:goods_id>/restock', methods=['POST'])
@permission_required('inventory_stocktake:operation')
# ★ 幂等锁必须置于 permission_required **内层**prevent_double_submit 依赖
# get_jwt_identity(),若放在外层则 JWT 尚未验证 → 抛错 → 被其 except 捕获
# 后 fail-open 降级放行,锁形同虚设。
@prevent_double_submit(lock_timeout=5)
def restock_defective_goods(goods_id):
"""
不良品修好后一键回库。
Body(JSON):
{
"restock_qty": 2, # 可选,缺省 = 全部剩余在管量
"remark": "已更换主板" # 可选
}
★ 与 trans_repair维修模块完全解耦本接口操作的是 trans_defective_goods
在管台账。trans_repair 是 SN 单台粒度且无任何数量列,承载不了「一批坏件」。
"""
data = request.get_json(silent=True) or {}
operator_name = _normalize_user_id()
try:
# ---- 1. 锁定在管记录 ----
# ★ 行锁不可省:并发两次回库若各自读到相同的 remaining_qty会双双通过
# 校验,合计回库量超过在管量 —— 凭空多出库存。
goods = TransDefectiveGoods.query.with_for_update().get(goods_id)
if not goods:
raise ValueError(f'不良品在管记录不存在ID: {goods_id}')
# ---- 2. 状态守门Fail-Closed----
# 已回库 → 再回库就是凭空多一份库存;已报废 → 实物已销毁。
if goods.status not in RESTOCKABLE_DEFECTIVE_STATUSES:
raise ValueError(
f'当前状态为「{goods.status}」,不可回库'
f'(仅 {"".join(RESTOCKABLE_DEFECTIVE_STATUSES)} 可回库)'
)
remaining = float(goods.remaining_qty or 0)
if remaining <= 0:
raise ValueError('该记录在管数量为 0无可回库数量')
raw = data.get('restock_qty')
if raw is None or raw == '':
restock_qty = remaining # 缺省:整批剩余一次回库
else:
try:
restock_qty = float(raw)
except (TypeError, ValueError):
raise ValueError('restock_qty 无效')
if restock_qty <= 0:
raise ValueError('回库数量必须大于 0')
if restock_qty > remaining:
raise ValueError(f'回库数量({restock_qty})超过在管数量({remaining})')
# ---- 3. 回到原库存行 ----
stock_row = _lock_source_stock_row(goods.source_table, goods.stock_id)
_assert_company_owns(stock_row)
current = (stock_row.status or '').strip()
if current != STOCK_STATUS_IN_STOCK:
raise ValueError(
f'原库存行当前状态为「{current or "未设置"}」,'
f'请先将其恢复为「{STOCK_STATUS_IN_STOCK}」再回库'
)
stock_row.stock_quantity = float(stock_row.stock_quantity or 0) + restock_qty
stock_row.available_quantity = float(stock_row.available_quantity or 0) + restock_qty
# ---- 4. 递减在管量、累加回库量并推进状态机 ----
new_remaining = remaining - restock_qty
goods.remaining_qty = new_remaining
goods.restocked_qty = float(goods.restocked_qty or 0) + restock_qty
# ★ 终态由「累计去向」推导而非「最后一次动作」:本批可能既回库过、
# 又报废过,按最后一次动作定状态会产生误导(见 defective_close_status
goods.status = (
defective_close_status(goods.restocked_qty, goods.scrapped_qty)
if new_remaining <= 0 else DEFECTIVE_STATUS_IN_PROGRESS
)
# ★ 刻意**不覆盖** goods.operator该字段记录的是「谁退回来的」
# 覆盖会丢掉退回环节的责任人。本次回库人由 audit_listener 自动
# 写入 audit_logstrans_defective_goods 已在审计白名单内)。
if data.get('remark'):
goods.remark = str(data['remark']).strip()
db.session.commit()
return jsonify({
'code': 200,
'msg': '回库成功',
'data': {
'id': goods.id,
'restock_qty': restock_qty,
'remaining_qty': float(goods.remaining_qty),
'status': goods.status,
'source_table': goods.source_table,
'stock_id': goods.stock_id,
},
}), 200
except PermissionError as e:
db.session.rollback()
return jsonify({'code': 403, 'msg': str(e)}), 403
except ValueError as e:
db.session.rollback()
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'回库失败: {str(e)}'}), 500
@bp.route('/defective/<int:goods_id>/scrap', methods=['POST'])
@permission_required('inventory_stocktake:operation')
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods
@prevent_double_submit(lock_timeout=5)
def scrap_defective_goods(goods_id):
"""
在管坏件报废(鉴定后确认无法维修,直接销毁)。
Body(JSON):
{
"scrap_qty": 2, # 可选,缺省 = 全部剩余在管量
"reason": "主板烧毁无法修复" # 必填
}
★ 与库存表的关系:坏件从未进入库存表(二期设计),因此本接口**不动任何
库存行**——它只做两件事:
1. 递减在管台账的 remaining_qty、累加 scrapped_qty、推进状态机
2. 往 trans_scrap 写一条报废台账,保证报废报表口径完整。
这与「库存行报废」(扣 stock_quantity / available_quantity是两条
互不重叠的路径,不会重复扣减。
"""
data = request.get_json(silent=True) or {}
operator_name = _normalize_user_id()
reason = (data.get('reason') or '').strip()
if not reason:
return jsonify({'code': 400, 'msg': '报废原因必填'}), 400
try:
# ---- 1. 锁定在管记录(并发下防超报废)----
goods = TransDefectiveGoods.query.with_for_update().get(goods_id)
if not goods:
raise ValueError(f'不良品在管记录不存在ID: {goods_id}')
# ---- 2. 状态守门Fail-Closed----
if goods.status not in SCRAPPABLE_DEFECTIVE_STATUSES:
raise ValueError(
f'当前状态为「{goods.status}」,不可报废'
f'(仅 {"".join(SCRAPPABLE_DEFECTIVE_STATUSES)} 可报废)'
)
remaining = float(goods.remaining_qty or 0)
if remaining <= 0:
raise ValueError('该记录在管数量为 0无可报废数量')
raw = data.get('scrap_qty')
if raw is None or raw == '':
scrap_qty = remaining # 缺省:整批剩余一次报废
else:
try:
scrap_qty = float(raw)
except (TypeError, ValueError):
raise ValueError('scrap_qty 无效')
if scrap_qty <= 0:
raise ValueError('报废数量必须大于 0')
if scrap_qty > remaining:
raise ValueError(f'报废数量({scrap_qty})超过在管数量({remaining})')
# ---- 3. 多租户隔离 ----
# 直接比对台账自身的 company_name 快照 —— 坏件的原库存行可能已被删除,
# 不能依赖联表取公司(那会让这类记录绕过隔离)。
company_limit = get_current_company_filter()
if company_limit is not None:
if (company_limit == '__NO_COMPANY__'
or (goods.company_name or '') != company_limit):
raise PermissionError('无权操作其他公司的不良品')
# ---- 4. 递减在管量、累加报废量、推进状态机 ----
new_remaining = remaining - scrap_qty
goods.remaining_qty = new_remaining
goods.scrapped_qty = float(goods.scrapped_qty or 0) + scrap_qty
goods.status = (
defective_close_status(goods.restocked_qty, goods.scrapped_qty)
if new_remaining <= 0 else DEFECTIVE_STATUS_IN_PROGRESS
)
# ---- 5. 写报废台账 ----
# ★ source_table 用 'trans_defective_goods'、stock_id 存台账自身主键,
# 与既有约定一致trans_borrow / trans_repair 作为来源时同样存各自主键)。
# 刻意**不**写原始库存表名 —— 那批坏件从未计入原库存行,若冒充库存
# 来源会让报废台账与库存表对不上账。
unit_price = _defective_unit_cost(goods)
db.session.add(TransScrap(
sku=goods.sku or '',
source_table='trans_defective_goods',
stock_id=goods.id,
quantity=scrap_qty,
reason=f"[不良品在管报废] {reason}",
operator_name=operator_name,
approval_status='approved', # 在管坏件鉴定后直接销毁,不走审批流
cost_at_scrap=unit_price,
total_loss=round(unit_price * scrap_qty, 2),
))
db.session.commit()
return jsonify({
'code': 200,
'msg': '报废成功',
'data': {
'id': goods.id,
'scrap_qty': scrap_qty,
'remaining_qty': float(goods.remaining_qty),
'restocked_qty': float(goods.restocked_qty),
'scrapped_qty': float(goods.scrapped_qty),
'status': goods.status,
},
}), 200
except PermissionError as e:
db.session.rollback()
return jsonify({'code': 403, 'msg': str(e)}), 403
except ValueError as e:
db.session.rollback()
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'报废失败: {str(e)}'}), 500

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',