Files
KCGL/inventory-backend/app/api/v1/scrap.py
yueli d1337d12e1 fix(scrap): 修复扫码时库存行遮蔽在管不良品导致的「不在批准明细中」
现象
----
报废申请单指向在管不良品时,待执行清单里明明能看到该 SKU,扫码却报
「不在该报废申请单的批准明细中,禁止报废」。

根因
----
在管不良品的 SKU 是从原库存行**复制**的(退回时 sku=getattr(stock_row,'sku','')),
因此同一个条码可能同时命中 stock_buy#M 与 trans_defective_goods#N —— 这是
**两个不同的实物**。

而 ScrapService.get_stock_by_barcode 原实现按固定顺序
(stock_product → stock_semi → stock_buy → trans_defective_goods)返回
**首个命中**。原库存行只要还在,就永远遮蔽在管不良品,扫码结果恒为
stock_buy。改造前双方都用 SKU 作为匹配键,遮蔽不暴露问题;上一轮给匹配键
加上来源表后,`sku:trans_defective_goods:X` ≠ `sku:stock_buy:X`,
问题才浮出水面。

实测复现(业务报障的 SKU 0000000590):
    stock_buy#589           在库 6 件
    trans_defective_goods#24 在管 1 件(同一 SKU)
    批准明细期望 -> ('trans_defective_goods', 24)
    扫码实际返回 -> stock_buy#589   → 键不匹配 → 报错

修复
----
条码本身无法区分这两个实物,**只有单据上下文能决定该扫到哪一个**,故把
单据上下文引入扫码解析:

- get_stock_by_barcode(barcode, prefer_pairs=None):改为**收集全部候选**,
  再按 prefer_pairs(当前申请单批准明细的 (source_table, stock_id) 集合)
  优先命中;无上下文时退回固定顺序,行为与改造前一致
- GET /scrap/scan 新增可选 request_id:据此加载该单的批准明细构造优先集。
  优先集构造失败不阻断扫码,仅告警并回退默认选路
- 前端 scanBarcode(barcode, requestId) 与 create.vue 调用处带上当前申请单 id

验证(隔离数据端到端,非仅单元):
    同一 SKU 建于 stock_buy#2202 与 trans_defective_goods#25
    不带上下文扫码 -> stock_buy#2202          (命中批准明细? False ← 旧行为)
    带上下文扫码   -> trans_defective_goods#25(命中批准明细? True)
    执行后:在管 2→0、状态=已报废;**库存行 10/10 分毫未动**(未误扣错误实物)
2026-09-16 17:22:51 +08:00

962 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# inventory-backend/app/api/v1/scrap.py
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, get_current_company_filter
from app.services.auth_service import AuthService
from app.extensions import db
from app.models.transaction import (
TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES,
)
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
from app.models.system import SysUser
import traceback
import math
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
# ==============================================================================
# 报废记录字段级权限过滤(对齐 outbound 的 filter_item_by_permissions
#
# 原先 /records 无条件剥离 cost_at_scrap / total_loss导致即使角色持有
# scrap_list:loss_amount 也看不到金额——与「损失金额」权限元素的存在相矛盾。
# 改为按权限决定可见性,与出库记录保持一致。
# ==============================================================================
def filter_scrap_by_permissions(item, user_permissions):
field_to_perm = {
'total_loss': 'scrap_list:loss_amount',
'loss_amount': 'scrap_list:loss_amount',
}
if 'scrap_list:*' in user_permissions:
return item
for field, perm_code in field_to_perm.items():
if field in item and perm_code not in user_permissions:
item[field] = None
for sub in (item.get('items') or []):
if isinstance(sub, dict):
filter_scrap_by_permissions(sub, user_permissions)
return item
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return []
if user_role.upper() == 'SUPER_ADMIN':
return ['scrap_list:*']
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
# --------------------------------------------------------
# 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/scrap/scan?barcode=...
# --------------------------------------------------------
@scrap_bp.route('/scan', methods=['GET'])
@jwt_required()
@permission_required('scrap_selection')
def scan_barcode():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
# ★ 可选:当前正在执行的报废申请单 id。
# 传入后,扫码命中多个来源时优先返回该单批准明细里指定的那一条。
# 必要性:在管不良品的 SKU 复制自原库存行,同一码可能同时命中
# stock_buy#M 与 trans_defective_goods#N这是两个不同实物
# 条码本身无法区分,只有单据上下文能决定该扫到哪个。
request_id = request.args.get('request_id', type=int)
prefer_pairs = None
if request_id:
try:
from app.models.scrap_approval import ScrapApproval
req = db.session.get(ScrapApproval, request_id)
if req:
prefer_pairs = set()
for it in (req.get_items() or []):
try:
prefer_pairs.add((
str(it.get('source_table') or '').strip(),
int(it.get('stock_id')),
))
except (TypeError, ValueError):
continue
except Exception as e:
# 优先集构造失败不应阻断扫码 —— 退回固定顺序选路,行为与改造前一致
current_app.logger.warning(f"[scrap/scan] 构造优先命中集失败: {e}")
prefer_pairs = None
try:
result = ScrapService.get_stock_by_barcode(barcode, prefer_pairs=prefer_pairs)
if result:
# ★ Fail-Closed: 扫码响应剥离价格字段
result.pop('price', None)
return jsonify({'code': 200, 'msg': '扫描成功', 'data': result})
else:
return jsonify({'code': 404, 'msg': '未找到对应的库存记录'}), 404
except Exception as e:
import traceback
traceback.print_exc() # 强制在控制台打印真实错误堆栈
return jsonify({"code": 500, "msg": f"服务器内部错误详情: {str(e)}"}), 500
# --------------------------------------------------------
# [已移除] 直接报废接口 POST /api/v1/scrap
#
# 该接口绕过审批流直接写 trans_scrap 并扣减库存,与系统自陈的规则
# 「报废一律需审批」services/scrap_approval_service.py 的
# SCRAP_ALWAYS_REQUIRES_APPROVAL直接冲突且构成职责分离漏洞 ——
# 同一个库管可自行宣告实物销毁而无人复核。
#
# 现全部报废必须走:申请 → 审批 → 执行
# 提交 POST /api/v1/scrap/request
# 审批 PATCH /api/v1/scrap/request/<id>/approve
# 执行 POST /api/v1/scrap/request/<id>/execute
#
# 前端 api/scrap.ts 的 createScrap() 已一并删除(此前已是死代码,
# 页面早已切换到申请-审批-按单执行流程)。
#
# 注:权限元素 scrap_create:operation 保留在库中不删 ——
# db_migrations/add_scrap_perm.sql 以它作为 scrap_apply / scrap_execute
# 的授权来源,删掉会让该迁移重跑时授不出权限。
# --------------------------------------------------------
# --------------------------------------------------------
# 2.1 报废申请专用库存列表(独立权限,解耦出库选单)
# GET /api/v1/scrap/stock-list
# --------------------------------------------------------
@scrap_bp.route('/stock-list', methods=['GET'])
@jwt_required()
@permission_required('scrap_apply')
def get_scrap_stock_list():
"""
报废申请专用库存列表 — Fail-Closed: 剥离价格字段
与借库 /transactions/borrow/stock-list 同构:复用 _do_get_stock_list
但挂 scrap_apply 权限,报废申请人无需持有出库选单权限。
"""
from app.api.v1.inbound.stock import _do_get_stock_list
return _do_get_stock_list(permission_prefix='scrap_apply')
# --------------------------------------------------------
# 3. 报废记录查询接口
# GET /api/v1/scrap/records
# --------------------------------------------------------
@scrap_bp.route('/records', methods=['GET'])
@jwt_required()
@permission_required('scrap_list')
def get_scrap_records():
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 50, type=int)
sku = request.args.get('sku', '')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
# ★ 高级筛选JSON 字符串 → 条件列表
from app.utils.advanced_filter import parse_advanced_filters
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
try:
result = ScrapService.query_records(
page=page,
page_size=page_size,
sku=sku,
start_date=start_date,
end_date=end_date,
keyword=keyword,
search_type=search_type,
advanced_filters=advanced_filters,
)
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
perms = get_current_user_permissions()
if 'scrap_list:*' not in perms:
for item in (result.get('list') or []):
filter_scrap_by_permissions(item, perms)
return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ============================================================
# Service 层:报废核心逻辑
# ============================================================
class ScrapService:
@staticmethod
def get_stock_by_barcode(barcode, prefer_pairs=None):
"""
根据条码查找库存实物。
prefer_pairs: {(source_table, row_id), ...} 可选 —— 「优先命中集」,
由调用方从**当前报废申请单的批准明细**构造。
★ 为什么需要它(这是必须的,不是优化):
在管不良品的 SKU 是从原库存行**复制**的(退回时
sku=getattr(stock_row,'sku','')),因此同一个条码可能同时命中
stock_buy#M 与 trans_defective_goods#N —— 这是**两个不同的实物**。
仅凭条码无法区分,原实现「按固定顺序返回首个命中」会让库存行
永久遮蔽不良品,导致执行时报「不在批准明细中」。
只有单据上下文能决定该扫到哪一个,故由调用方传入优先集。
返回值形状与改造前完全一致,前端无需按来源分支处理。
"""
if not barcode:
return None
clean_code = barcode.strip()
def get_price(item, table_type):
if table_type == 'stock_product':
return float(item.sale_price) if item.sale_price else 0
elif table_type == 'stock_buy':
return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0
return 0
# ---- 收集全部候选(不再首个即返回)----
candidates = []
prod = StockProduct.query.filter(
db.or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
).first()
if prod:
res = ScrapService._format_stock(prod, 'stock_product')
res['price'] = get_price(prod, 'stock_product')
candidates.append(res)
semi = StockSemi.query.filter(
db.or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
).first()
if semi:
res = ScrapService._format_stock(semi, 'stock_semi')
res['price'] = 0
candidates.append(res)
buy = StockBuy.query.filter(
db.or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
).first()
if buy:
res = ScrapService._format_stock(buy, 'stock_buy')
res['price'] = get_price(buy, 'stock_buy')
candidates.append(res)
# 4. 查询在管不良品台账(逆向物流:坏件不入库存表,由独立台账承载)
#
# ★ 本分支取代原先的「维修单」分支。维修件报废能力已随直接报废接口
# 一并移除(见文件头部 [已移除] 注释),且该来源早已被审批流的扫码
# 校验判死 —— _build_scanned_index 会拒绝 trans_* 来源。
#
# ★ 物料名/规格直接取自台账的冗余快照字段,**不联表 MaterialBase** ——
# 坏件的原库存行可能已被入库模块物理删除,联表会取到空值。
defective = TransDefectiveGoods.query.filter(
TransDefectiveGoods.sku == clean_code
).filter(
# 仅未结案的可扫:终态(已回库/已报废/已闭环)在管量已归零
TransDefectiveGoods.status.in_(OPEN_DEFECTIVE_STATUSES)
).first()
if defective:
# 在管量即「可报废量」,回填到既有字段形状,前端无需按来源分支处理
remain = float(defective.remaining_qty or 0)
candidates.append({
'id': defective.id,
'sku': defective.sku,
'barcode': defective.sku,
'name': defective.material_name or '在管不良品',
'spec': defective.spec_model or '',
'category': '',
'material_type': '',
'warehouse_loc': '', # 台账无库位字段
'stock_quantity': remain,
'available_quantity': remain,
'source_table': 'trans_defective_goods',
})
if not candidates:
return None
# ---- 选路:单据上下文优先,否则维持改造前的固定顺序(首个命中)----
# ★ 同一 SKU 同时存在于库存表与在管台账时,条码无法区分二者;
# 批准明细说该报废哪一个,就返回哪一个。
if prefer_pairs:
for res in candidates:
try:
pair = (str(res.get('source_table') or '').strip(), int(res.get('id')))
except (TypeError, ValueError):
continue
if pair in prefer_pairs:
return res
return candidates[0]
@staticmethod
def _format_stock(item, table_type):
"""格式化库存查询结果 - 使用安全 getattr 防止属性错误"""
return {
'id': getattr(item, 'id', None),
'sku': getattr(item, 'sku', ''),
'barcode': getattr(item, 'barcode', getattr(item, 'bar_code', '')),
'name': item.base.name if getattr(item, 'base', None) else '',
'spec': item.base.spec_model if getattr(item, 'base', None) else '',
'category': item.base.category if getattr(item, 'base', None) else '',
'material_type': item.base.material_type if getattr(item, 'base', None) else '',
'warehouse_loc': getattr(item, 'warehouse_location', ''),
'stock_quantity': float(getattr(item, 'stock_quantity', getattr(item, 'qty_stock', 0)) or 0),
'available_quantity': float(getattr(item, 'available_quantity', getattr(item, 'qty_available', 0)) or 0),
'source_table': table_type,
}
# ------------------------------------------------------------------
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
# ------------------------------------------------------------------
@staticmethod
def _display_name(raw):
if raw is None or raw == '':
return ''
s = str(raw)
if s.isdigit():
u = SysUser.query.get(int(s))
if u and u.username:
s = u.username
return s.split('/')[0] if '/' in s else s
# ------------------------------------------------------------------
# 辅助:批量解析报废行的物料名称/规格/库位/批号(多态 5 条来源路径)
# ------------------------------------------------------------------
@staticmethod
def _resolve_materials(rows):
from app.models.transaction import TransBorrow
from sqlalchemy.orm import joinedload
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
}
resolved = {}
def _from_stock(stock_row):
if not stock_row:
return None
base = getattr(stock_row, 'base', None)
return {
'material_name': (base.name if base else '') or '',
'spec_model': (base.spec_model if base else '') or '',
'warehouse_location': getattr(stock_row, 'warehouse_location', '') or '',
'batch_number': (getattr(stock_row, 'batch_number', '')
or getattr(stock_row, 'serial_number', '') or ''),
}
# 1) 常规库存三表:批量拉取并 joinedload 物料主表
for table, model in model_map.items():
ids = {r.stock_id for r in rows if r.source_table == table and r.stock_id}
if not ids:
continue
for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all():
resolved[(table, obj.id)] = _from_stock(obj)
# 2) 借库转报废stock_id 指向 trans_borrow.id再经其 source_table 找库存
borrow_ids = {r.stock_id for r in rows if r.source_table == 'trans_borrow' and r.stock_id}
if borrow_ids:
borrows = TransBorrow.query.filter(TransBorrow.id.in_(borrow_ids)).all()
inner_ids = {}
for b in borrows:
if b.source_table in model_map and b.stock_id:
inner_ids.setdefault(b.source_table, set()).add(b.stock_id)
inner_map = {}
for table, ids in inner_ids.items():
model = model_map[table]
for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all():
inner_map[(table, obj.id)] = _from_stock(obj)
for b in borrows:
info = inner_map.get((b.source_table, b.stock_id))
if info:
resolved[('trans_borrow', b.id)] = info
# 3) 维修单来源:物料名在 TransRepair 上,无规格
repair_ids = {r.stock_id for r in rows if r.source_table == 'trans_repair' and r.stock_id}
if repair_ids:
for rp in TransRepair.query.filter(TransRepair.id.in_(repair_ids)).all():
resolved[('trans_repair', rp.id)] = {
'material_name': rp.material_name or '',
'spec_model': '',
'warehouse_location': '',
'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
def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
keyword='', search_type='all', advanced_filters=None):
"""
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
· 有 scrap_request_no按单号分组
· 无单号(历史直接报废):按 操作时间(精确到分钟) + 操作人 虚拟分组,
并生成可读的虚拟单号,避免历史数据成为无主记录。
每单返回 items 明细数组、损失合计、申请人(取自 ScrapApproval
"""
query = TransScrap.query
if sku:
query = query.filter(TransScrap.sku.like(f'%{sku}%'))
if start_date:
query = query.filter(TransScrap.operation_time >= start_date)
if end_date:
query = query.filter(TransScrap.operation_time <= end_date + ' 23:59:59')
# 单号 / SKU 可在 SQL 层直接过滤
if keyword and search_type == 'no':
query = query.filter(TransScrap.scrap_request_no.ilike(f'%{keyword}%'))
elif keyword and search_type == 'sku':
query = query.filter(TransScrap.sku.ilike(f'%{keyword}%'))
# ====================================================================
# ★ 高级筛选
#
# 报废流水在 Python 侧按「单号 / (时间+操作人) 虚拟单号」分组,
# 且历史数据 scrap_request_no 为 NULL无法像出库/借还那样直接对
# 单号列做 IN。因此这里统一用**行级等价键**表达父子关系:
#
# 1) 先用子级条件求出「命中行」;
# 2) 把命中行折算成"同单等价键"谓词;
# 3) 肯定操作符 → 放行同单全部行OR
# 否定操作符 → 整单排除NOT ...)。
#
# 等价键:有单号 → scrap_request_no 相等;
# 无单号 → 同一分钟 + 同一操作人(与分组逻辑完全一致)。
# ====================================================================
if advanced_filters:
from app.utils.advanced_filter import (
build_predicate, is_negative, invert_condition,
)
from sqlalchemy import or_, and_, tuple_, case, func as _func
parent_map = {
'no': TransScrap.scrap_request_no,
'operator': TransScrap.operator_name,
}
child_map = {'sku': TransScrap.sku}
def _order_key_pred(rows):
"""把若干命中行折算成「同单」谓词OR 连接)"""
keys = []
for h in rows:
if h.scrap_request_no:
keys.append(and_(
TransScrap.scrap_request_no.isnot(None),
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
def _matched_rows(cond):
"""按肯定形式求出「命中该子级条件的报废行」"""
probe = invert_condition(cond)
field = probe.get('field')
if field == 'material_name':
# 物料名不在流水表:先在三张库存表求出 (source_table, stock_id)
# 命中集合,再用元组 IN 定位报废行。
# ★ 用元组 IN 而非"单号 IN":后者对 scrap_request_no 为 NULL 的
# 历史行永远匹配不上,会把历史数据整体漏掉。
from app.models.base import MaterialBase
name_col = MaterialBase.name
name_pred = (name_col == probe.get('value')
if probe.get('operator') == 'eq'
else name_col.ilike(f"%{probe.get('value')}%"))
pairs = []
for SM, sv in [(StockBuy, 'stock_buy'), (StockSemi, 'stock_semi'),
(StockProduct, 'stock_product')]:
for r in (SM.query.join(MaterialBase, SM.base_id == MaterialBase.id)
.filter(name_pred).all()):
pairs.append((sv, r.id))
if not pairs:
return None
return TransScrap.query.filter(
tuple_(TransScrap.source_table, TransScrap.stock_id).in_(pairs)
).all()
p = build_predicate(probe, child_map)
if p is None:
return None
return TransScrap.query.filter(p).all()
for cond in advanced_filters:
field = cond.get('field')
# --- 父级字段:标准 SQL 谓词,否定操作符语义无歧义 ---
if field in parent_map:
p = build_predicate(cond, parent_map)
if p is not None:
query = query.filter(p)
continue
if field not in child_map and field != 'material_name':
continue
# --- 子级字段:正/负操作符语义分派 ---
negative = is_negative(cond)
rows = _matched_rows(cond)
if not rows:
# 没有命中行:肯定 → 结果为空;否定 → 无需排除任何单
if not negative:
query = query.filter(db.false())
continue
key_pred = _order_key_pred(rows)
if key_pred is None:
continue
query = query.filter(~key_pred if negative else key_pred)
# 【行级数据隔离】基于 JWT 多租户公司过滤
# 通过 stock 表或 trans_repair 关联到 MaterialBase
company_limit = get_current_company_filter()
if company_limit is not None:
buy_subq = db.session.query(TransScrap.id).join(
StockBuy, db.and_(TransScrap.stock_id == StockBuy.id,
TransScrap.source_table == 'stock_buy')
).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
semi_subq = db.session.query(TransScrap.id).join(
StockSemi, db.and_(TransScrap.stock_id == StockSemi.id,
TransScrap.source_table == 'stock_semi')
).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
product_subq = db.session.query(TransScrap.id).join(
StockProduct, db.and_(TransScrap.stock_id == StockProduct.id,
TransScrap.source_table == 'stock_product')
).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
repair_subq = db.session.query(TransScrap.id).join(
TransRepair, db.and_(TransScrap.stock_id == TransRepair.id,
TransScrap.source_table == 'trans_repair')
).join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
# ★ 借库转报废stock_id 指向 trans_borrow.id通过 borrow 关联源库存表再关联 MaterialBase
from app.models.transaction import TransBorrow
borrow_stock_buy = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).join(StockBuy, TransBorrow.stock_id == StockBuy.id).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
borrow_stock_semi = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).join(StockSemi, TransBorrow.stock_id == StockSemi.id).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
borrow_stock_product = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).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,
defective_subq
).subquery()
query = query.filter(TransScrap.id.in_(all_matches))
# 按时间倒序
query = query.order_by(TransScrap.operation_time.desc())
# ==================================================================
# ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页
# 有单号 → 按 scrap_request_no
# 无单号 → 按 操作时间(分钟) + 操作人 虚拟分组
# ==================================================================
rows = query.all()
mat_info = ScrapService._resolve_materials(rows)
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 '未知时间'
# ★ 分组键带上来源标记:不良品直报自成一类,不与普通直接报废
# 混进同一组(二者前缀不同,混组会导致标题语义不一致)。
# 注意此键必须与下方 _order_key_pred 的 SQL 谓词保持一致。
gkey = ('legacy', ts, r.operator_name or '', is_defective_origin)
g = groups.get(gkey)
if g is None:
op_name = ScrapService._display_name(r.operator_name)
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'
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',
'operator_name': op_name,
'applicant_id': None,
'applicant_name': '',
'scrap_time': r.operation_time.strftime('%Y-%m-%d %H:%M:%S') if r.operation_time else '',
'_sort_time': r.operation_time,
'approval_status': r.approval_status or '',
'total_loss': 0.0,
'total_quantity': 0.0,
'reason': r.reason or '',
'items': [],
}
groups[gkey] = g
info = mat_info.get((r.source_table, r.stock_id), {})
qty = float(r.quantity or 0)
loss = float(r.total_loss or 0)
g['total_quantity'] += qty
g['total_loss'] += loss
g['items'].append({
'id': r.id,
'sku': r.sku or '',
'material_name': info.get('material_name', ''),
'spec_model': info.get('spec_model', ''),
'warehouse_location': info.get('warehouse_location', ''),
'batch_number': info.get('batch_number', ''),
'quantity': qty,
'reason': r.reason or '',
'source_table': r.source_table or '',
'loss_amount': round(loss, 2),
})
orders = list(groups.values())
# ★ 补申请人:有单号的取 ScrapApproval无单号的以操作人兜底
req_nos = [o['scrap_request_no'] for o in orders if not o['is_legacy']]
if req_nos:
from app.models.scrap_approval import ScrapApproval
approver_cache = {}
for ap in ScrapApproval.query.filter(
ScrapApproval.request_no.in_(req_nos)
).all():
if ap.applicant_id not in approver_cache:
# 统一走 _display_name去掉 username 里的 '/账号' 后缀
approver_cache[ap.applicant_id] = ScrapService._display_name(
ScrapApproval._user_name(ap.applicant_id)
)
o = groups.get(('req', ap.request_no))
if o:
o['applicant_id'] = ap.applicant_id
o['applicant_name'] = approver_cache.get(ap.applicant_id, '')
for o in orders:
if not o['applicant_name']:
o['applicant_name'] = o['operator_name']
o['total_loss'] = round(o['total_loss'], 2)
o['items'].sort(key=lambda x: x['sku'] or '')
# 关键词过滤:单号/SKU 已在 SQL 层处理,这里处理操作人/申请人/物料名
if keyword and search_type in ('all', 'name', 'material_name'):
kw = keyword.lower()
def _hit(o):
if search_type == 'name':
return kw in (o['operator_name'] or '').lower() or kw in (o['applicant_name'] or '').lower()
if search_type == 'material_name':
return any(kw in (it['material_name'] or '').lower() for it in o['items'])
return (kw in (o['scrap_request_no'] or '').lower()
or kw in (o['operator_name'] or '').lower()
or kw in (o['applicant_name'] or '').lower()
or any(kw in (it['sku'] or '').lower()
or kw in (it['material_name'] or '').lower()
or kw in (it['spec_model'] or '').lower() for it in o['items']))
orders = [o for o in orders if _hit(o)]
orders.sort(key=lambda o: (o['_sort_time'] is None, o['_sort_time']), reverse=True)
total = len(orders)
start = (page - 1) * page_size
paged = orders[start:start + page_size]
for o in paged:
o.pop('_sort_time', None)
return {
'list': paged,
'total': total,
'page': page,
'pageSize': page_size
}
# ==============================================================================
# 报废审批流(申请 → 审批 → 按单执行)—— 镜像 借库/出库 审批框架
# 与旧 /scrap直接报废并存旧入口不动
# ==============================================================================
def _current_user_role():
try:
claims = get_jwt()
return (claims.get('role') or '').upper()
except Exception:
return ''
@scrap_bp.route('/request/check-approval', methods=['POST'])
@jwt_required()
@permission_required('scrap_apply')
def scrap_check_approval():
"""
提交前预检:报废一律需审批,本接口返回 need_approval=true
并附带命中「需审批物料」标记的明细,供前端展示审批提示文案。
"""
try:
from app.services.approval_control import resolve_approval_control
from app.services.scrap_approval_service import SCRAP_ALWAYS_REQUIRES_APPROVAL
data = request.get_json() or {}
items = data.get('items', []) or []
_, flagged = resolve_approval_control(items)
return jsonify({'code': 200, 'msg': 'success',
'data': {'need_approval': SCRAP_ALWAYS_REQUIRES_APPROVAL,
'materials': flagged}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'预检失败: {str(e)}'}), 500
@scrap_bp.route('/request', methods=['POST'])
@jwt_required()
@permission_required('scrap_apply')
def create_scrap_request():
"""提交报废申请(不扣库存;扣减在库管执行时)"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {}
req = ScrapApprovalService.submit_approval(
applicant_id=int(identity),
items=data.get('items', []),
allowed_approvers=data.get('allowed_approvers'),
remark=data.get('remark'),
approver_id=data.get('approver_id'),
force_approval=(_current_user_role() == 'WAREHOUSE_MGR'),
)
return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
@scrap_bp.route('/request', methods=['GET'])
@jwt_required()
@permission_required('scrap_approval')
def list_scrap_requests():
"""
报废申请列表。
scope: mine(我提交的,默认) / pending(待我审批) / executable(待执行) / all(管理者)
Query: page, limit, status(可选覆盖)
"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
from app.utils.decorators import is_privileged_viewer
identity = int(get_jwt_identity())
scope = (request.args.get('scope') or 'mine').lower()
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
status = request.args.get('status')
status = int(status) if status not in (None, '', 'all') else None
priv = is_privileged_viewer()
kwargs = {'page': page, 'limit': limit, 'status': status}
if scope == 'pending':
kwargs['status'] = 0
kwargs['approver_id'] = identity
elif scope == 'executable':
kwargs['status'] = 1
elif scope == 'all':
if not priv:
kwargs['applicant_id'] = identity
else: # mine
kwargs['applicant_id'] = identity
data = ScrapApprovalService.get_list(**kwargs)
return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取报废申请列表失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>', methods=['GET'])
@jwt_required()
@permission_required('scrap_approval')
def get_scrap_request_detail(request_id):
try:
from app.models.scrap_approval import ScrapApproval
from app.utils.decorators import is_privileged_viewer
req = db.session.get(ScrapApproval, request_id)
if not req:
return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404
if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()):
# 被指定审批人也可查看
allowed = req.get_allowed_approvers() or []
if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']:
return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取报废申请详情失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
@permission_required('scrap_approval')
def approve_scrap_request(request_id):
"""审批报废申请(仅被指定审批人)"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
data = request.get_json() or {}
req = ScrapApprovalService.approve(
request_id, int(get_jwt_identity()),
data.get('action'), data.get('reject_reason', '')
)
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>/withdraw', methods=['POST'])
@jwt_required()
def withdraw_scrap_request(request_id):
"""
撤回自己的报废申请单(待审批 或 已通过但未执行)。
★ 严格职责分离:本端点**不做模块权限校验**@jwt_required 即可),
权限判定完全落在「单据归属」上 —— 服务层断言
applicant_id == 当前用户,否则 403。库管/主管可代撤。
与出库/借库的撤回语义一致:作废该单并释放其预占库存
报废模块若未接入预占release_reserved 会安全跳过)。
"""
try:
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
from app.services.scrap_approval_service import ScrapApprovalService
from app.utils.decorators import is_privileged_viewer
# 审批页调用时调用方已有 scrap_approval 权限;此处统一走归属校验,
# 特权角色可代撤is_privileged_viewer 在服务层内判定)。
req = ScrapApprovalService.withdraw(
request_id,
operator_id=int(identity),
require_owner=not is_privileged_viewer(),
)
return jsonify({'code': 200, 'msg': '报废申请已撤回', 'data': req.to_dict()}), 200
except PermissionError as e:
return jsonify({'code': 403, 'msg': str(e)}), 403
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'撤回报废申请失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>/execute', methods=['POST'])
@jwt_required()
@permission_required('scrap_execute')
def execute_scrap_request(request_id):
"""
按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水。
Body: { "items": [{"source_table", "stock_id", "quantity", "name"/"sku"(可选)}, ...] }
实扫明细必须是该申请单批准明细的子集,且累计数量不得超过批准数量。
"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
identity = int(get_jwt_identity())
u = SysUser.query.get(identity)
data = request.get_json(silent=True) or {}
req = ScrapApprovalService.execute(
request_id,
operator_name=(u.username if u else str(identity)),
scanned_items=data.get('items') or [],
)
return jsonify({'code': 200, 'msg': '已执行报废并扣减库存', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'执行报废失败: {str(e)}'}), 500