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:
yueli
2026-09-10 17:21:14 +08:00
parent a07432981e
commit ec66c33b06
4 changed files with 334 additions and 0 deletions

View 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())