diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index fa8580c..1230e9f 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -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() + 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