From aeb852c64d9ef4a2238b854a4b809955ffe353f5 Mon Sep 17 00:00:00 2001 From: yueli Date: Fri, 11 Sep 2026 13:28:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(stocktake):=20=E6=96=B0=E5=A2=9E=E7=9B=98?= =?UTF-8?q?=E7=82=B9=E4=BC=9A=E8=AF=9D=E8=A1=A8=20StocktakeSession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 盘点原本只有 stocktake_draft(草稿行),没有会话实体,导致三个问题: 1. 盲盘/明盘、全盘/抽盘这类**会话级配置**无处存放,塞进草稿表就要每行 冗余一份,改一次配置得 UPDATE 上万行; 2. 「空会话」无法表示 —— 会话开了但还没扫码时草稿表里没有行,旧实现只能 靠 max(scan_time) 猜哪个会话活跃,于是新开的空会话会被其他 PDA 忽略、 反而加入上一轮的旧会话,多端协同直接分裂; 3. 没有状态字段,generate-missing 跑完后旧会话仍被当成活跃。 本表把活跃会话判定变成 company_name + status='active' 的精确查询。 迁移的要点: - 唯一部分索引 uq_stocktake_session_one_active 保证「一个公司同时只有一个 活跃会话」,从数据库层挡住多台 PDA 并发开启导致的进度分裂; 因此 /draft/start-new 必须先把原有活跃会话置为 finished 再插入新记录。 - CHECK 约束限定 mode/scope_type/status 的取值域。 - 不回填存量:旧草稿没有对应会话行,部署后显示「无进行中的盘点」,数据不丢。 执行: docker exec -i inventory_db psql -U test -d inventory_system < db_migrations/add_stocktake_session.sql --- db_migrations/add_stocktake_session.sql | 57 ++++++++++++++++++ .../app/models/inbound/stocktake.py | 58 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 db_migrations/add_stocktake_session.sql diff --git a/db_migrations/add_stocktake_session.sql b/db_migrations/add_stocktake_session.sql new file mode 100644 index 0000000..be3943f --- /dev/null +++ b/db_migrations/add_stocktake_session.sql @@ -0,0 +1,57 @@ +-- ============================================================================= +-- 一次性迁移:盘点会话表(stocktake_session) +-- +-- 背景 +-- 盘点原本只有 stocktake_draft(草稿行),没有任何「会话」实体,导致: +-- 1. 盲盘/明盘、全盘/抽盘这类**会话级配置**无处存放; +-- 2. 「空会话」无法表示 —— 会话开了但还没扫码时表里没有行, +-- 旧实现只能靠 max(scan_time) 猜哪个会话活跃,于是新开的空会话 +-- 会被其他 PDA 忽略、反而加入上一轮的旧会话; +-- 3. 没有状态字段,「已结束」的会话仍会被当成活跃会话返回。 +-- +-- 本表把活跃会话判定变成 company_name + status='active' 的精确查询。 +-- +-- 唯一部分索引 +-- 保证「一个公司同时只能有一个 status='active' 的会话」。 +-- 多台 PDA 同时点「开启新盘点」时,后到的那条会在数据库层被挡住, +-- 不会产生两个并行活跃会话导致进度分裂。 +-- 因此 /draft/start-new 必须先把该公司原有的活跃会话置为 finished +-- 再插入新记录(同一事务内完成)。 +-- +-- 存量数据 +-- 不做回填。stocktake_draft 里的历史会话没有对应的会话行, +-- 部署后会显示为「无进行中的盘点」,旧草稿仍留在原表不受影响。 +-- +-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件 +-- ============================================================================= +BEGIN; + +CREATE TABLE IF NOT EXISTS stocktake_session ( + session_id varchar(100) PRIMARY KEY, + company_name varchar(255) NOT NULL, + -- open=明盘(可看账面数) / blind=盲盘(隐藏账面数与差异) + mode varchar(20) NOT NULL DEFAULT 'open', + -- full=全仓盘点 / active=活跃库位抽盘 + scope_type varchar(20) NOT NULL DEFAULT 'full', + -- 范围明细,例: {"days":30,"top_n":50,"locations":[...],"uuids":[...]} + scope_config jsonb NOT NULL DEFAULT '{}'::jsonb, + status varchar(20) NOT NULL DEFAULT 'active', + created_by varchar(100), + created_at timestamp without time zone DEFAULT timezone('Asia/Shanghai', now()), + finished_at timestamp without time zone, + + CONSTRAINT ck_stocktake_session_mode CHECK (mode IN ('open', 'blind')), + CONSTRAINT ck_stocktake_session_scope CHECK (scope_type IN ('full', 'active')), + CONSTRAINT ck_stocktake_session_status CHECK (status IN ('active', 'finished')) +); + +-- 活跃会话查询的主路径 +CREATE INDEX IF NOT EXISTS ix_stocktake_session_company_status + ON stocktake_session(company_name, status); + +-- ★ 一个公司同时只能有一个活跃会话 +CREATE UNIQUE INDEX IF NOT EXISTS uq_stocktake_session_one_active + ON stocktake_session(company_name) + WHERE status = 'active'; + +COMMIT; diff --git a/inventory-backend/app/models/inbound/stocktake.py b/inventory-backend/app/models/inbound/stocktake.py index 54c62fa..a804eea 100644 --- a/inventory-backend/app/models/inbound/stocktake.py +++ b/inventory-backend/app/models/inbound/stocktake.py @@ -2,6 +2,64 @@ from app.extensions import db, beijing_time # .material -> .base refactor check from datetime import datetime +# 盘点模式 +STOCKTAKE_MODE_OPEN = 'open' # 明盘:可看到账面数 +STOCKTAKE_MODE_BLIND = 'blind' # 盲盘:隐藏账面数与差异 + +# 盘点范围 +STOCKTAKE_SCOPE_FULL = 'full' # 全仓盘点 +STOCKTAKE_SCOPE_ACTIVE = 'active' # 活跃库位抽盘(范围过滤尚未实现,暂不可选) + +# 会话状态 +STOCKTAKE_STATUS_ACTIVE = 'active' +STOCKTAKE_STATUS_FINISHED = 'finished' + + +class StocktakeSession(db.Model): + """ + 盘点会话表 —— 一次盘点的「主心骨」。 + + 为什么需要独立一张表(而不是把配置冗余进 stocktake_draft): + 1. 盲盘/明盘、全盘/抽盘都是**会话级**属性,塞进草稿表就要每行存一份, + 改一次配置得 UPDATE 上万行; + 2. stocktake_draft 没有任何「会话存在但还没扫码」的表示能力 —— + 空会话没有草稿行,旧实现只能靠 max(scan_time) 去猜哪个会话是活跃的, + 导致新开的空会话被其他 PDA 忽略、反而加入上一轮的旧会话; + 3. 没有状态字段就无法把「已结束」的会话排除掉,generate-missing 跑完后 + 旧会话仍被当成活跃。 + + 有了本表,活跃会话判定变成 company_name + status='active' 的精确查询。 + """ + __tablename__ = 'stocktake_session' + + session_id = db.Column(db.String(100), primary_key=True) + company_name = db.Column(db.String(255), nullable=False, index=True) + # open(明盘) / blind(盲盘) —— 决定 merged-list 是否下发账面数 + mode = db.Column(db.String(20), nullable=False, default=STOCKTAKE_MODE_OPEN) + # full(全仓) / active(活跃库位抽盘) + scope_type = db.Column(db.String(20), nullable=False, default=STOCKTAKE_SCOPE_FULL) + # 范围明细,例: {"days":30,"top_n":50,"locations":[...],"uuids":[...]} + scope_config = db.Column(db.JSON, nullable=False, default=dict) + # active / finished + status = db.Column(db.String(20), nullable=False, default=STOCKTAKE_STATUS_ACTIVE, index=True) + created_by = db.Column(db.String(100)) + created_at = db.Column(db.DateTime, default=beijing_time) + finished_at = db.Column(db.DateTime) + + def to_dict(self): + return { + 'session_id': self.session_id, + 'company_name': self.company_name, + 'mode': self.mode, + 'scope_type': self.scope_type, + 'scope_config': self.scope_config or {}, + 'status': self.status, + 'created_by': self.created_by, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None, + 'finished_at': self.finished_at.strftime('%Y-%m-%d %H:%M:%S') if self.finished_at else None, + } + + class StocktakeDraft(db.Model): """ 盘点草稿表