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:
@ -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_loss(best-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_logs(trans_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
|
||||
|
||||
@ -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