# inventory-backend/app/api/v1/scan_draft.py """ 扫码草稿接口(出库 / 借库共用)。 场景 ---- 扫码作业可能很长(一张单几十项),工人常需中途暂停去处理更紧急的单据。 改造前切换单据会清空已扫内容,刷新/退出页面则全部丢失。 由于库存在申请审批通过时已**预占**,暂停期间货不会被他人抢走 —— 因此草稿只记录「扫到哪了」,**不涉及任何库存操作**。即使草稿丢失也只是 需要重扫,不会造成库存错乱。 隔离 ---- 按 (user_id, biz_type, request_id) 隔离:一人一单,互不影响。 user_id 一律取自 JWT,**不接受入参覆盖**,因此不可能读写他人的草稿。 """ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity import traceback from app.extensions import db from app.models.scan_draft import ScanDraft scan_draft_bp = Blueprint('scan_draft', __name__) BIZ_TYPES = {'outbound', 'borrow'} def _parse_identity(): identity = get_jwt_identity() if not identity: return None, None, None, '用户未登录' biz_type = (request.args.get('biz_type') or '').strip() if biz_type not in BIZ_TYPES: return None, None, None, f"biz_type 无效(仅支持 {', '.join(sorted(BIZ_TYPES))})" request_id = request.args.get('request_id', type=int) if not request_id: return None, None, None, 'request_id 不能为空' return int(identity), biz_type, request_id, None def _find(user_id, biz_type, request_id): return ScanDraft.query.filter_by( user_id=user_id, biz_type=biz_type, request_id=request_id ).first() # -------------------------------------------------------- # 读取草稿:GET /api/v1/scan-draft?biz_type=outbound&request_id=123 # -------------------------------------------------------- @scan_draft_bp.route('', methods=['GET']) @jwt_required() def get_scan_draft(): """ 读取当前用户在某张单据上的扫码草稿。 返回 { items: [...完整购物车快照...], item_count, total_qty, updated_at } 无草稿时 items 为空数组(非 404,便于前端直接使用)。 """ try: user_id, biz_type, request_id, err = _parse_identity() if err: return jsonify({'code': 400, 'msg': err}), 400 draft = _find(user_id, biz_type, request_id) items = draft.get_items() if draft else [] return jsonify({ 'code': 200, 'msg': 'success', 'data': { 'items': items, 'item_count': len(items), 'total_qty': round(sum(float(i.get('out_quantity') or 0) for i in items), 4), 'updated_at': ( draft.updated_at.strftime('%Y-%m-%d %H:%M:%S') if draft and draft.updated_at else None ), } }), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'读取草稿失败: {str(e)}'}), 500 # -------------------------------------------------------- # 保存草稿:POST /api/v1/scan-draft # Body: { biz_type, request_id, request_no?, items: [...完整购物车快照...] } # # ★ 全量覆盖语义:提交的 items 即当前完整清单。 # 前端把 cartItems 整体发过来即可,不必逐条比对增删 —— 逻辑单一, # 也不会出现「已移除的物料在草稿里阴魂不散」。 # # ★ 注意:items 为空时会**删除**该单据的草稿(而非存一个空草稿)。 # 这符合「清空列表 = 放弃这次作业」的直觉,也避免残留空记录影响徽标。 # -------------------------------------------------------- @scan_draft_bp.route('', methods=['POST']) @jwt_required() def save_scan_draft(): try: identity = get_jwt_identity() if not identity: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 user_id = int(identity) data = request.get_json(silent=True) or {} biz_type = (data.get('biz_type') or '').strip() if biz_type not in BIZ_TYPES: return jsonify({'code': 400, 'msg': f"biz_type 无效(仅支持 {', '.join(sorted(BIZ_TYPES))})"}), 400 try: request_id = int(data.get('request_id')) except (TypeError, ValueError): return jsonify({'code': 400, 'msg': 'request_id 无效'}), 400 request_no = (data.get('request_no') or '')[:100] items = data.get('items') or [] draft = _find(user_id, biz_type, request_id) # 空清单 → 删除草稿 if not items: if draft: db.session.delete(draft) db.session.commit() return jsonify({'code': 200, 'msg': 'success', 'data': {'saved': 0, 'cleared': True}}), 200 if draft is None: draft = ScanDraft(user_id=user_id, biz_type=biz_type, request_id=request_id) db.session.add(draft) draft.request_no = request_no draft.set_items(items) db.session.commit() return jsonify({ 'code': 200, 'msg': 'success', 'data': {'saved': len(items)} }), 200 except Exception as e: db.session.rollback() traceback.print_exc() return jsonify({'code': 500, 'msg': f'保存草稿失败: {str(e)}'}), 500 # -------------------------------------------------------- # 清除草稿:DELETE /api/v1/scan-draft?biz_type=outbound&request_id=123 # 提交成功后调用,避免下次打开该单据时恢复出已提交的内容。 # -------------------------------------------------------- @scan_draft_bp.route('', methods=['DELETE']) @jwt_required() def clear_scan_draft(): try: user_id, biz_type, request_id, err = _parse_identity() if err: return jsonify({'code': 400, 'msg': err}), 400 draft = _find(user_id, biz_type, request_id) deleted = 0 if draft: db.session.delete(draft) db.session.commit() deleted = 1 return jsonify({'code': 200, 'msg': 'success', 'data': {'deleted': deleted}}), 200 except Exception as e: db.session.rollback() traceback.print_exc() return jsonify({'code': 500, 'msg': f'清除草稿失败: {str(e)}'}), 500 # -------------------------------------------------------- # 草稿概览:GET /api/v1/scan-draft/overview?biz_type=outbound # # 供单据下拉显示「已扫 N 项」进度徽标 —— 工人扫开页面就能看到哪张单 # 之前扫到一半,不必逐个点开试。 # -------------------------------------------------------- @scan_draft_bp.route('/overview', methods=['GET']) @jwt_required() def get_draft_overview(): try: identity = get_jwt_identity() if not identity: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 biz_type = (request.args.get('biz_type') or '').strip() if biz_type not in BIZ_TYPES: return jsonify({'code': 400, 'msg': 'biz_type 无效'}), 400 rows = ScanDraft.query.filter_by( user_id=int(identity), biz_type=biz_type ).all() drafts = [] for r in rows: items = r.get_items() if not items: continue # 防御:残留的空草稿不参与展示 drafts.append({ 'request_id': r.request_id, 'request_no': r.request_no or '', 'item_count': len(items), 'total_qty': round(sum(float(i.get('out_quantity') or 0) for i in items), 4), 'updated_at': r.updated_at.strftime('%Y-%m-%d %H:%M:%S') if r.updated_at else None, }) return jsonify({'code': 200, 'msg': 'success', 'data': {'drafts': drafts}}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'获取草稿概览失败: {str(e)}'}), 500