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