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 项)、全量覆盖、
提交后清除,全部符合预期。
This commit is contained in:
42
db_migrations/add_scan_draft.sql
Normal file
42
db_migrations/add_scan_draft.sql
Normal file
@ -0,0 +1,42 @@
|
||||
-- =============================================================================
|
||||
-- 扫码草稿表(scan_draft)
|
||||
--
|
||||
-- 背景
|
||||
-- 扫码出库/借库的作业过程可能很长(一张单几十项),工人常需中途暂停去处理
|
||||
-- 更紧急的单据。改造前切换单据会直接清空已扫内容,刷新/退出页面更是全部丢失。
|
||||
--
|
||||
-- 库存已在申请审批通过时**预占**,暂停期间不会被他人抢走 —— 因此草稿只需
|
||||
-- 记录「扫到哪了」,不涉及任何库存操作,纯状态保存。
|
||||
--
|
||||
-- 隔离粒度:(user_id, biz_type, request_id)
|
||||
-- 一人一单 —— 每个人扫自己的草稿,互不影响;同一人可同时持有多张单据的草稿。
|
||||
--
|
||||
-- 为什么整单存一个 JSON(而非每条明细一行)
|
||||
-- 1. 保存是「全量覆盖」语义,逐行存并无增量更新的收益;
|
||||
-- 2. 恢复时需要物料名称/规格/库位等展示字段,逐行存方案只能回查申请单
|
||||
-- 的 items_json —— 而历史单据的 items_json 不含 stock_id,回查会错配。
|
||||
-- 整单快照把展示字段一并存下,恢复时零依赖、对老单据同样可靠。
|
||||
--
|
||||
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS scan_draft;
|
||||
|
||||
CREATE TABLE scan_draft (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
biz_type varchar(20) NOT NULL, -- outbound / borrow
|
||||
request_id integer NOT NULL, -- 关联的申请单ID
|
||||
request_no varchar(100), -- 单号快照,便于展示免联表
|
||||
-- 整单快照:[{stock_id, source_table, sku, name, spec_model,
|
||||
-- warehouse_location, available_quantity, out_quantity, price}, ...]
|
||||
items_json jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
updated_at timestamp without time zone NOT NULL DEFAULT timezone('Asia/Shanghai', now()),
|
||||
created_at timestamp without time zone NOT NULL DEFAULT timezone('Asia/Shanghai', now()),
|
||||
CONSTRAINT uq_scan_draft UNIQUE (user_id, biz_type, request_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_scan_draft_lookup ON scan_draft(user_id, biz_type);
|
||||
|
||||
COMMIT;
|
||||
@ -197,6 +197,20 @@ def create_app():
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: MyRequests 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.85 扫码草稿(出库/借库作业中途暂停用)
|
||||
# 按 (user_id, biz_type, request_id) 隔离,仅需登录态。
|
||||
# 模型在此显式导入,确保审计监听器注册时已进入 db.metadata。
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.models.scan_draft import ScanDraft # noqa: F401
|
||||
from app.api.v1.scan_draft import scan_draft_bp
|
||||
app.register_blueprint(scan_draft_bp, url_prefix='/api/v1/scan-draft')
|
||||
app.register_blueprint(scan_draft_bp, url_prefix='/api/scan-draft', name='scan_draft_legacy')
|
||||
print("✅ ScanDraft 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: ScanDraft 模块导入失败: {e}")
|
||||
|
||||
# 2.9 初始化审计日志菜单和权限(防重复)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
|
||||
219
inventory-backend/app/api/v1/scan_draft.py
Normal file
219
inventory-backend/app/api/v1/scan_draft.py
Normal file
@ -0,0 +1,219 @@
|
||||
# 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
|
||||
59
inventory-backend/app/models/scan_draft.py
Normal file
59
inventory-backend/app/models/scan_draft.py
Normal file
@ -0,0 +1,59 @@
|
||||
import json
|
||||
|
||||
from app.extensions import db, beijing_time
|
||||
|
||||
|
||||
class ScanDraft(db.Model):
|
||||
"""
|
||||
扫码草稿(出库 / 借库作业中途暂停用)
|
||||
|
||||
场景:一张单几十项,工人扫到一半需要去处理更紧急的单据,事后回来继续。
|
||||
库存已在申请审批通过时预占,暂停期间不会被他人抢走,因此草稿只需记录
|
||||
「扫到哪了」,不涉及库存操作。
|
||||
|
||||
隔离粒度 (user_id, biz_type, request_id):一人一单;同一人可同时持有
|
||||
多张单据的草稿(正是「暂停 A 去出 B」的场景)。
|
||||
|
||||
为什么整单存 JSON 而非逐条明细行:
|
||||
恢复时需要物料名称/规格/库位等展示字段。逐行存方案只能回查申请单的
|
||||
items_json —— 而历史单据的 items_json 不含 stock_id,回查会错配。
|
||||
整单快照把展示字段一并存下,恢复时零依赖,对老单据同样可靠。
|
||||
"""
|
||||
__tablename__ = 'scan_draft'
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'biz_type', 'request_id', name='uq_scan_draft'),
|
||||
)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
biz_type = db.Column(db.String(20), nullable=False) # outbound / borrow
|
||||
request_id = db.Column(db.Integer, nullable=False)
|
||||
request_no = db.Column(db.String(100)) # 单号快照,展示免联表
|
||||
# ★ 必须用 db.JSON 而非 db.Text —— 数据库列是 jsonb
|
||||
# (见 db_migrations/add_scan_draft.sql)。
|
||||
# db.Text 会让 psycopg2 拿到 Python list/dict 时无法适配,
|
||||
# 报 "can't adapt type 'dict'";而用 db.Text 存 json.dumps 的字符串
|
||||
# 又会与 jsonb 列的类型语义打架。类型一致才是正解。
|
||||
items_json = db.Column(db.JSON, nullable=False, default=list)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
|
||||
|
||||
def set_items(self, items):
|
||||
self.items_json = items or []
|
||||
|
||||
def get_items(self):
|
||||
v = self.items_json
|
||||
if not v:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
# 兼容历史数据(若该列曾被写成 JSON 字符串)
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
|
||||
@property
|
||||
def item_count(self):
|
||||
return len(self.get_items())
|
||||
Reference in New Issue
Block a user