Files
KCGL/inventory-backend/app/api/v1/scan_draft.py
yueli ec66c33b06 feat(scan-draft): 扫码草稿表与接口,支持暂停后继续扫码
场景
----
扫码出库/借库的作业可能很长(一张单几十项),工人常需中途暂停去处理
更紧急的单据。改造前切换单据会清空已扫内容,刷新/退出页面则全部丢失。

由于库存在申请审批通过时已**预占**,暂停期间货不会被他人抢走 ——
因此草稿只记录「扫到哪了」,**不涉及任何库存操作**。即使草稿丢失也只是
需要重扫,不会造成库存错乱。

隔离粒度
--------
按 (user_id, biz_type, request_id) 一人一单:每个人扫自己的草稿,互不影响;
同一人可同时持有多张单据的草稿(正是「暂停 A 去出 B」的场景)。
user_id 一律取自 JWT,不接受入参覆盖,故不可能读写他人草稿。

为什么整单存一个 JSON(而非每条明细一行)
------------------------------------------
1. 保存是「全量覆盖」语义,逐行存无增量更新的收益;
2. 恢复时需要物料名称/规格/库位等展示字段,逐行方案只能回查申请单的
   items_json —— 而历史单据的 items_json 不含 stock_id,回查会错配。
   整单快照把展示字段一并存下,恢复零依赖,对老单据同样可靠。

接口
----
GET    /api/v1/scan-draft           读取草稿
POST   /api/v1/scan-draft           保存(全量覆盖;空清单则删除)
DELETE /api/v1/scan-draft           清除(提交成功后调用)
GET    /api/v1/scan-draft/overview  各单据进度,供下拉徽标

实现要点
--------
· items_json 列是 jsonb,模型必须用 db.JSON —— 用 db.Text 会让 psycopg2
  拿到 Python list/dict 时无法适配,报 "can't adapt type 'dict'",而异常
  被接口的 except 吞掉后 POST 仍返回"成功",问题极难发现(开发中实际踩到);
· 概览接口做防御:残留的空草稿不参与展示。

实测:保存/读回、跨单据隔离(B 读 A 的草稿为 0 项)、全量覆盖、
      提交后清除,全部符合预期。
2026-09-10 17:21:14 +08:00

220 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# inventory-backend/app/api/v1/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