From ec66c33b06f6d146177f3f7bef189b0713911b3b Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 10 Sep 2026 17:21:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(scan-draft):=20=E6=89=AB=E7=A0=81=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E8=A1=A8=E4=B8=8E=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=9A=82=E5=81=9C=E5=90=8E=E7=BB=A7=E7=BB=AD=E6=89=AB?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 场景 ---- 扫码出库/借库的作业可能很长(一张单几十项),工人常需中途暂停去处理 更紧急的单据。改造前切换单据会清空已扫内容,刷新/退出页面则全部丢失。 由于库存在申请审批通过时已**预占**,暂停期间货不会被他人抢走 —— 因此草稿只记录「扫到哪了」,**不涉及任何库存操作**。即使草稿丢失也只是 需要重扫,不会造成库存错乱。 隔离粒度 -------- 按 (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 项)、全量覆盖、 提交后清除,全部符合预期。 --- db_migrations/add_scan_draft.sql | 42 ++++ inventory-backend/app/__init__.py | 14 ++ inventory-backend/app/api/v1/scan_draft.py | 219 +++++++++++++++++++++ inventory-backend/app/models/scan_draft.py | 59 ++++++ 4 files changed, 334 insertions(+) create mode 100644 db_migrations/add_scan_draft.sql create mode 100644 inventory-backend/app/api/v1/scan_draft.py create mode 100644 inventory-backend/app/models/scan_draft.py diff --git a/db_migrations/add_scan_draft.sql b/db_migrations/add_scan_draft.sql new file mode 100644 index 0000000..81cc6a1 --- /dev/null +++ b/db_migrations/add_scan_draft.sql @@ -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; diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index 5e4af32..e3609ca 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -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: diff --git a/inventory-backend/app/api/v1/scan_draft.py b/inventory-backend/app/api/v1/scan_draft.py new file mode 100644 index 0000000..fc5f8ad --- /dev/null +++ b/inventory-backend/app/api/v1/scan_draft.py @@ -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 diff --git a/inventory-backend/app/models/scan_draft.py b/inventory-backend/app/models/scan_draft.py new file mode 100644 index 0000000..3009abc --- /dev/null +++ b/inventory-backend/app/models/scan_draft.py @@ -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())