Files
KCGL/inventory-backend/app/models/inbound/stocktake.py
yueli aeb852c64d feat(stocktake): 新增盘点会话表 StocktakeSession
盘点原本只有 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
2026-09-11 13:28:43 +08:00

108 lines
4.7 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.

from app.extensions import db, beijing_time # .material -> .base refactor checked
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):
"""
盘点草稿表
临时工作台,盘点时使用,会话结束后清空
"""
__tablename__ = 'stocktake_draft'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.String(100), default='admin')
# 关联的库存UUID (sku/barcode)
uuid = db.Column(db.String(100))
# 实际盘点数量
quantity = db.Column(db.Numeric(19, 4), default=1)
scan_time = db.Column(db.DateTime, default=beijing_time)
# 盘点会话标识 (用于区分不同批次的盘点)
session_id = db.Column(db.String(100))
# 关联的库存类型 (stock_buy/stock_semi/stock_product)
source_table = db.Column(db.String(50))
# 关联的库存ID
stock_id = db.Column(db.Integer)
# 账面库存数量 (记录盘点时的账面数量)
stock_qty = db.Column(db.Numeric(19, 4), default=0)
# 差异数量 (实盘 - 账面, 正=盘盈, 负=盘亏)
diff_qty = db.Column(db.Numeric(19, 4), default=0)
# 盘点备注(可选)
remark = db.Column(db.String(255), comment='盘点备注(可选)')
# 所属公司(多租户隔离键)。取自 material_base.company_name;
# 超管/跨域用户在未指定公司时为 NULL,表示不做公司过滤。
company_name = db.Column(db.String(255), index=True, comment='所属公司')
def to_dict(self):
return {
'id': self.id,
'user_id': self.user_id,
'uuid': self.uuid,
'quantity': float(self.quantity or 1),
'scan_time': self.scan_time.strftime('%Y-%m-%d %H:%M:%S') if self.scan_time else None,
'session_id': self.session_id,
'source_table': self.source_table,
'stock_id': self.stock_id,
'stock_qty': float(self.stock_qty or 0),
'diff_qty': float(self.diff_qty or 0),
'remark': self.remark,
'company_name': self.company_name
}