feat(stocktake): start-new 落库会话配置,active-session 改查会话表

- /draft/start-new 接收 mode / scope_type / scope_config,插入一条
  StocktakeSession(status='active')。同一公司原有活跃会话先置为 finished
  (同一事务),否则会撞 uq_stocktake_session_one_active。
  公司取值:普通用户强制本公司;跨域角色必须显式指定,否则 400。
  scope_type 目前只放行 'full' —— 抽盘的范围过滤尚未实现,此时放行会导致
  结束盘点时 generate-missing 把抽盘范围外的库存批量标成盘亏。

- /draft/active-session 改为直接查 stocktake_session
  (company_name + status='active'),并返回 mode / scope_type 供前端展示。
  旧实现靠 max(scan_time) 从草稿行猜,空会话识别不了。

- /stocktake/generate-missing 漏盘比对完成后把会话置为 finished,
  否则 active-session 会继续把它当活跃会话,其他 PDA 会加入一个已结束的盘点。

实测:
  ① 超管+IRIS 创建 blind/full 会话 → 200
  ② SALES/IRIS 查活跃会话 → 拿到同一 session_id 与 mode=blind
     (scanned=0 的空会话也能查到,旧实现此处返回 null)
  ③ 再开一轮 → superseded_count=1,旧的转 finished
  ④ scope_type=active → 400「活跃库位抽盘尚未实现」
  ⑤ DB 层验证唯一索引拒绝重复活跃会话
This commit is contained in:
yueli
2026-09-11 13:28:49 +08:00
parent aeb852c64d
commit 3f964614e3

View File

@ -12,7 +12,15 @@ from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
# 导入模型
from app.models.inbound.buy import StockBuy
from app.models.inbound.stocktake import StocktakeDraft
from app.models.inbound.stocktake import (
StocktakeDraft,
StocktakeSession,
STOCKTAKE_MODE_OPEN,
STOCKTAKE_MODE_BLIND,
STOCKTAKE_SCOPE_FULL,
STOCKTAKE_STATUS_ACTIVE,
STOCKTAKE_STATUS_FINISHED,
)
from app.models.transaction import TransBorrow
from app.models.base import MaterialBase
@ -828,16 +836,22 @@ def get_stocktake_companies():
@permission_required('inventory_stocktake')
def get_active_session():
"""
获取当前公司最近一次活跃的盘点会话。
获取当前公司正在进行的盘点会话。
多人多设备协同PDA 进入盘点页先问这个接口 —— 若本司已有同事开过的会话,
多人多设备协同PDA 进入盘点页先问这个接口 —— 若本司已有同事开过的会话,
直接加入该 session_id而不是各开各的。
「活跃」的定义:该公司所有草稿中 scan_time 最大的那个 session_id
注意 /draft/list 是按 SKU 排序的,拿不到「最近会话」,故单列此接口。
★ 改造:改为直接查 stocktake_sessioncompany_name + status='active'
旧实现靠 max(scan_time) 从草稿行里猜「最近会话」,有两个先天缺陷:
1. 空会话(刚开启还没扫码)在草稿表里没有行,会被误判为「无盘点」,
其他 PDA 于是各开各的,会话分裂;
2. 已结束的会话没有状态标记generate-missing 跑完后仍被当成活跃。
现在活跃判定是精确查询,两个问题一并解决。
返回: {
session_id: str|null,
mode: 'open'|'blind'|null, # 明盘/盲盘
scope_type: 'full'|'active'|null,# 全仓/抽盘
total: int, # 该会话的草稿行数
scanned: int, # 实际扫到的件数(按物料去重,排除系统自动生成的漏盘记录)
last_scan_time: str|null,
@ -846,36 +860,30 @@ def get_active_session():
"""
company_name = get_current_company_filter()
empty = {
'session_id': None, 'mode': None, 'scope_type': None,
'total': 0, 'scanned': 0, 'last_scan_time': None, 'initiator': None
}
# 跨域角色未指定公司时无法确定「哪个公司的活跃会话」,直接返回无
if not company_name or company_name == '__NO_COMPANY__':
return jsonify({'code': 200, 'data': empty}), 200
try:
query = db.session.query(
StocktakeDraft.session_id.label('session_id'),
db.func.count(StocktakeDraft.id).label('total'),
db.func.max(StocktakeDraft.scan_time).label('last_scan_time')
).filter(StocktakeDraft.session_id.isnot(None))
session = StocktakeSession.query.filter_by(
company_name=company_name, status=STOCKTAKE_STATUS_ACTIVE
).order_by(StocktakeSession.created_at.desc()).first()
if company_name is not None:
query = query.filter(StocktakeDraft.company_name == company_name)
if not session:
return jsonify({'code': 200, 'data': empty}), 200
row = query.group_by(StocktakeDraft.session_id).order_by(
db.func.max(StocktakeDraft.scan_time).desc()
).first()
session_id = session.session_id
if not row or not row.session_id:
return jsonify({
'code': 200,
'data': {
'session_id': None, 'total': 0, 'scanned': 0,
'last_scan_time': None, 'initiator': None
}
}), 200
session_id = row.session_id
# 该会话的明细统计 —— 必须同样带上公司条件,避免跨公司串号
def _scoped(q):
if company_name is not None:
q = q.filter(StocktakeDraft.company_name == company_name)
return q.filter(StocktakeDraft.session_id == session_id)
return q.filter(
StocktakeDraft.company_name == company_name,
StocktakeDraft.session_id == session_id
)
# 已扫件数:按 (source_table, stock_id) 去重。
# 排除 user_id == 'system' 的记录 —— 那是「结束盘点」时 generate-missing
@ -885,6 +893,13 @@ def get_active_session():
.filter(StocktakeDraft.user_id != 'system')
).distinct().count()
agg = _scoped(
db.session.query(
db.func.count(StocktakeDraft.id).label('total'),
db.func.max(StocktakeDraft.scan_time).label('last_scan_time')
)
).first()
# 发起人:该会话最早一条非系统扫码记录的操作人。
# 注:/draft/add 更新已有记录时会把 user_id 改写成「最后操作者」,
# 所以这里取到的是最早那条记录的最后操作人,是当前模型下最接近发起人的信号。
@ -898,9 +913,11 @@ def get_active_session():
'code': 200,
'data': {
'session_id': session_id,
'total': int(row.total or 0),
'mode': session.mode,
'scope_type': session.scope_type,
'total': int(agg.total or 0) if agg else 0,
'scanned': int(scanned or 0),
'last_scan_time': row.last_scan_time.strftime('%Y-%m-%d %H:%M:%S') if row.last_scan_time else None,
'last_scan_time': agg.last_scan_time.strftime('%Y-%m-%d %H:%M:%S') if agg and agg.last_scan_time else None,
'initiator': initiator
}
}), 200
@ -913,20 +930,75 @@ def get_active_session():
@permission_required('inventory_stocktake:operation')
def start_new_session():
"""
开始新一轮盘点 —— 只签发新的 session_id,不删除任何历史数据
开始新一轮盘点:签发 session_id 并落库一条 StocktakeSession(status='active')
★ 改造:原实现执行 StocktakeDraft.query.all() 并逐条 delete
「开启新盘点」会清空**整张表**,抹掉其他公司、其他用户正在进行的盘点进度。
现改为纯签发:新旧会话靠 session_id 天然隔离,历史草稿原样保留,
需要清理时走 /draft/clear必须显式指定 session_id
- 不删除任何历史草稿:新旧会话靠 session_id 隔离,清理走 /draft/clear
- 同一公司同时只允许一个活跃会话DB 唯一部分索引兜底),
因此先把该公司原有的活跃会话置为 finished再插入新记录同一事务
"""
data = request.get_json(silent=True) or {}
# 公司:普通用户强制取本公司;跨域角色(超管/WAREHOUSE_MGR必须显式指定
company_limit = get_current_company_filter()
if company_limit == '__NO_COMPANY__':
return jsonify({"message": "当前账号未绑定公司,无法开始盘点"}), 400
if company_limit is None:
company_name = (data.get('company_name') or '').strip()
if not company_name or company_name.upper() == 'ALL':
return jsonify({"message": "请先选择要盘点的公司"}), 400
else:
company_name = company_limit
mode = (data.get('mode') or STOCKTAKE_MODE_OPEN).strip().lower()
if mode not in (STOCKTAKE_MODE_OPEN, STOCKTAKE_MODE_BLIND):
return jsonify({"message": f"不支持的盘点模式: {mode}"}), 400
scope_type = (data.get('scope_type') or STOCKTAKE_SCOPE_FULL).strip().lower()
if scope_type != STOCKTAKE_SCOPE_FULL:
# ★ 抽盘的范围过滤all-items / generate-missing尚未实现此处必须先挡住
# 范围没生效就等于全仓盘点,而结束盘点时 generate-missing 会把
# 抽盘范围之外的所有库存批量标成盘亏 —— 会直接污染库存账。
return jsonify({"message": "活跃库位抽盘尚未实现,当前仅支持全仓盘点"}), 400
scope_config = data.get('scope_config') or {}
if not isinstance(scope_config, dict):
return jsonify({"message": "scope_config 必须是对象"}), 400
new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}"
return jsonify({
"message": "已开启新盘点会话(历史草稿保留)",
"session_id": new_session_id,
"cleared_count": 0
}), 200
try:
# 先给本公司原有的活跃会话收尾,否则会撞 uq_stocktake_session_one_active
superseded = StocktakeSession.query.filter_by(
company_name=company_name, status=STOCKTAKE_STATUS_ACTIVE
).all()
for s in superseded:
s.status = STOCKTAKE_STATUS_FINISHED
s.finished_at = beijing_time()
db.session.add(StocktakeSession(
session_id=new_session_id,
company_name=company_name,
mode=mode,
scope_type=scope_type,
scope_config=scope_config,
status=STOCKTAKE_STATUS_ACTIVE,
created_by=_normalize_user_id(),
))
db.session.commit()
return jsonify({
"message": "已开启新盘点会话(历史草稿保留)",
"session_id": new_session_id,
"company_name": company_name,
"mode": mode,
"scope_type": scope_type,
"superseded_count": len(superseded),
"cleared_count": 0
}), 200
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({"message": str(e)}), 500
# --- 盘点结束与差异报告 ---
@ -1698,15 +1770,25 @@ def generate_missing_stocktake():
db.session.commit()
# ★ 会话收尾:漏盘比对完成即视为该轮盘点结束。
# 不置为 finished 的话,/draft/active-session 会继续把它当活跃会话返回,
# 其他 PDA 会「加入」一个已经结束的盘点。
finished_session = StocktakeSession.query.filter_by(
session_id=session_id, status=STOCKTAKE_STATUS_ACTIVE
).first()
if finished_session:
finished_session.status = STOCKTAKE_STATUS_FINISHED
finished_session.finished_at = beijing_time()
db.session.commit()
return jsonify({
'code': 200,
'msg': f'成功生成 {missing_count} 条漏盘记录',
'data': {'count': missing_count}
'data': {'count': missing_count, 'session_finished': bool(finished_session)}
})
except Exception as e:
db.session.rollback()
import traceback
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'生成漏盘数据失败: {str(e)}'}), 500