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:
@ -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_session(company_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()
|
||||
|
||||
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))
|
||||
|
||||
if company_name is not None:
|
||||
query = query.filter(StocktakeDraft.company_name == company_name)
|
||||
|
||||
row = query.group_by(StocktakeDraft.session_id).order_by(
|
||||
db.func.max(StocktakeDraft.scan_time).desc()
|
||||
).first()
|
||||
|
||||
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
|
||||
empty = {
|
||||
'session_id': None, 'mode': None, 'scope_type': None,
|
||||
'total': 0, 'scanned': 0, 'last_scan_time': None, 'initiator': None
|
||||
}
|
||||
}), 200
|
||||
|
||||
session_id = row.session_id
|
||||
# 跨域角色未指定公司时无法确定「哪个公司的活跃会话」,直接返回无
|
||||
if not company_name or company_name == '__NO_COMPANY__':
|
||||
return jsonify({'code': 200, 'data': empty}), 200
|
||||
|
||||
try:
|
||||
session = StocktakeSession.query.filter_by(
|
||||
company_name=company_name, status=STOCKTAKE_STATUS_ACTIVE
|
||||
).order_by(StocktakeSession.created_at.desc()).first()
|
||||
|
||||
if not session:
|
||||
return jsonify({'code': 200, 'data': empty}), 200
|
||||
|
||||
session_id = session.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]}"
|
||||
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user