Files
KCGL/inventory-backend/app/api/v1/scrap.py

962 lines
45 KiB
Python
Raw Normal View History

# inventory-backend/app/api/v1/scrap.py
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
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
refactor(audit): 审计架构清理——复活白名单监听器、停用噪声监听器、清除僵尸装饰器 一、统一为单一监听器实现 原先两套 SQLAlchemy 事件监听器并存: · app/utils/audit_events.py —— 全局监听 db.Model、无白名单、无请求上下文守卫(实际在跑) · app/core/audit_listener.py —— 白名单制、有守卫、有模型级开关(从未生效) 后者失效的根因:注册代码写在 extensions.py 的 init_extensions() 内, 而该函数全仓库只有定义、没有任何调用(create_app 直接内联调用 db.init_app 等)。 现统一由 app/core/audit_listener.py 承担,并在 create_app() 中显式注册。 extensions.py 的死函数 init_extensions 整体删除,避免后人误以为它是有效入口。 二、修复监听器三处致命缺陷(此前注册了也写不进数据) 1. 事件回调第二个参数是 Connection,原代码却调用 Connection.add()(不存在), 每次写日志都抛 AttributeError 并被 except 吞掉 → 改为 connection.execute() 2. register_audit_listeners 从 app.models 批量 import 多个未导出的模型, ImportError 被上层 try/except 吞掉 → 改为按表名从 db.metadata 取模型 3. 本项目有 31 处函数体内延迟导入模型(如 scrap.py 内部才 import ScrapApproval), 一次性注册会静默漏表 → 增加 ensure_audit_listeners() 惰性补绑, 并在模型预加载段补全审批单/BOM/采购等模型 三、强约束 · WHITELIST_TABLES:仅 18 张核心业务表,系统表/草稿表/向量表不再自审 · has_request_context() 守卫:系统初始化与后台定时任务不再产生 username=system 噪声 · IGNORE_FIELDS 增加 password/password_hash/salt/token/secret/api_key(安全红线) · created_at 显式写 beijing_time(),与全系统时间口径一致 四、清除僵尸装饰器 @audit_log 早已退化为直接透传的空壳(module/action 参数全被忽略, 数据库中零星的中文 action 即其历史遗留产物),却仍挂在 38 处路由上。 连同 13 个文件的 import 一并移除;audit_events.register_audit_events 改为空操作。 验证:应用上下文中的写操作不产生日志;HTTP 请求产生 5 条日志, 对象为业务单号(APR-SCRAP-... / SKU),模块中文,操作人真实,时间为北京时间。
2026-09-10 14:16:27 +08:00
from app.utils.decorators import permission_required, get_current_company_filter
from app.services.auth_service import AuthService
from app.extensions import db
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
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')
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
# ==============================================================================
# 报废记录字段级权限过滤(对齐 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
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
# ★ 可选:当前正在执行的报废申请单 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:
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
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
# --------------------------------------------------------
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
# [已移除] 直接报废接口 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', '')
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
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,
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
end_date=end_date,
keyword=keyword,
search_type=search_type,
advanced_filters=advanced_filters,
)
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
# 损失金额按 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
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
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
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
# ---- 收集全部候选(不再首个即返回)----
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')
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
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
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
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')
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
candidates.append(res)
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
# 4. 查询在管不良品台账(逆向物流:坏件不入库存表,由独立台账承载)
#
# ★ 本分支取代原先的「维修单」分支。维修件报废能力已随直接报废接口
# 一并移除(见文件头部 [已移除] 注释),且该来源早已被审批流的扫码
# 校验判死 —— _build_scanned_index 会拒绝 trans_* 来源。
#
# ★ 物料名/规格直接取自台账的冗余快照字段,**不联表 MaterialBase** ——
# 坏件的原库存行可能已被入库模块物理删除,联表会取到空值。
defective = TransDefectiveGoods.query.filter(
TransDefectiveGoods.sku == clean_code
).filter(
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
# 仅未结案的可扫:终态(已回库/已报废/已闭环)在管量已归零
TransDefectiveGoods.status.in_(OPEN_DEFECTIVE_STATUSES)
).first()
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
if defective:
# 在管量即「可报废量」,回填到既有字段形状,前端无需按来源分支处理
remain = float(defective.remaining_qty or 0)
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
candidates.append({
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
'id': defective.id,
'sku': defective.sku,
'barcode': defective.sku,
'name': defective.material_name or '在管不良品',
'spec': defective.spec_model or '',
'category': '',
'material_type': '',
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
'warehouse_loc': '', # 台账无库位字段
'stock_quantity': remain,
'available_quantity': remain,
'source_table': 'trans_defective_goods',
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
})
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
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
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,
}
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
# ------------------------------------------------------------------
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
# ------------------------------------------------------------------
@staticmethod
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
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 '',
}
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
# 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': '',
}
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
return resolved
@staticmethod
def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
keyword='', search_type='all', advanced_filters=None):
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
"""
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
· 有 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')
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
# 单号 / 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,
)
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
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:
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
# ★ 来源标记必须与内存分组键(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,
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
_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)
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
# ★ 不良品在管报废(三期):台账自带 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,
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
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())
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
# ==================================================================
# ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页
# 有单号 → 按 scrap_request_no
# 无单号 → 按 操作时间(分钟) + 操作人 虚拟分组
# ==================================================================
rows = query.all()
mat_info = ScrapService._resolve_materials(rows)
groups = {}
for r in rows:
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
# ★ 不良品在管报废(source_table='trans_defective_goods')同样没有审批
# 单号,但它是**新业务**产生的记录,不是历史脏数据。沿用 LEGACY-
# 前缀会让业务人员误判为遗留数据,故单独成组并换用「不良品直报-」。
is_defective_origin = (r.source_table == 'trans_defective_goods')
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
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 '未知时间'
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
# ★ 分组键带上来源标记:不良品直报自成一类,不与普通直接报废
# 混进同一组(二者前缀不同,混组会导致标题语义不一致)。
# 注意此键必须与下方 _order_key_pred 的 SQL 谓词保持一致。
gkey = ('legacy', ts, r.operator_name or '', is_defective_origin)
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
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:
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
# 虚拟单号:无审批单号的直接报废,给出可读标识便于追溯
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
ts_raw = r.operation_time.strftime('%Y%m%d%H%M') if r.operation_time else '000000000000'
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 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
prefix = '不良品直报' if is_defective_origin else 'LEGACY'
req_no = f"{prefix}-{ts_raw}-{op_name or '未知'}"
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
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 {
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索 一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
2026-09-10 12:06:04 +08:00
'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
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
@scrap_bp.route('/request/<int:request_id>/withdraw', methods=['POST'])
@jwt_required()
def withdraw_scrap_request(request_id):
"""
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
撤回自己的报废申请单(待审批 或 已通过但未执行)。
★ 严格职责分离:本端点**不做模块权限校验**(@jwt_required 即可),
权限判定完全落在「单据归属」上 —— 服务层断言
applicant_id == 当前用户,否则 403。库管/主管可代撤。
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
与出库/借库的撤回语义一致:作废该单并释放其预占库存
(报废模块若未接入预占,release_reserved 会安全跳过)。
"""
try:
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
from app.services.scrap_approval_service import ScrapApprovalService
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
from app.utils.decorators import is_privileged_viewer
# 审批页调用时调用方已有 scrap_approval 权限;此处统一走归属校验,
# 特权角色可代撤(is_privileged_viewer 在服务层内判定)。
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
req = ScrapApprovalService.withdraw(
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
request_id,
operator_id=int(identity),
require_owner=not is_privileged_viewer(),
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
)
return jsonify({'code': 200, 'msg': '报废申请已撤回', 'data': req.to_dict()}), 200
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
except PermissionError as e:
return jsonify({'code': 403, 'msg': str(e)}), 403
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
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