From b8d18c71d8ac10cd66396b7afb01b51e51da823b Mon Sep 17 00:00:00 2001 From: yueli Date: Fri, 11 Sep 2026 11:33:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(stocktake):=20=E7=9B=98=E7=82=B9=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E6=8C=89=E5=85=AC=E5=8F=B8=E9=9A=94=E7=A6=BB=EF=BC=8C?= =?UTF-8?q?=E5=BC=80=E5=90=AF=E6=96=B0=E7=9B=98=E7=82=B9=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E6=95=B4=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复三处跨公司数据污染: 1. /draft/start-new 原本执行 StocktakeDraft.query.all() 后逐条 delete, 任一库管点「开启新盘点」就会物理删除全公司所有人的盘点进度。 改为只签发新 session_id,历史数据原样保留;清理走 /draft/clear, 且强制要求 session_id(不传直接 400),杜绝误清整表。 2. get_stock_info() 全局按 barcode/sku 匹配三张库存表,不同公司的同码 物料互相串货。新增 company_name 参数,精确与模糊两段查询均经由 base 关系按 material_base.company_name 过滤;草稿去重键同步改为 (uuid, session_id, company_name)。 3. 盘点相关读写接口统一施加公司隔离:/draft/list、/draft/add、/draft/clear、 /variance-report、/draft/merged-list、/stocktake/all-items、 /stocktake/generate-missing、/stocktake/update-quantity、/export-stocktake、 /adjust、/scan。 顺带修复 /export-stocktake:差异与相符两张 Sheet 原本不按 session 过滤, 过去依赖 start-new 清空整表才恰好等价于当前会话,现显式按 session_id 过滤, 否则历史会话会被一并导出。 新增接口: - GET /stocktake/companies 盘点页公司下拉(普通用户只返回本公司, 避免复用 /inbound/buy/options 时因缺 inbound_buy 权限而 403) - GET /draft/active-session 该公司最近一次活跃会话,供多设备加入 --- inventory-backend/app/api/v1/inbound/stock.py | 315 ++++++++++++++---- 1 file changed, 250 insertions(+), 65 deletions(-) diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index fcb12c9..34f0628 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -2,7 +2,7 @@ from flask import Blueprint, jsonify, request, send_file, current_app from app.extensions import db, beijing_time from datetime import datetime, timedelta from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity -from app.utils.decorators import permission_required +from app.utils.decorators import permission_required, get_current_company_filter from sqlalchemy.orm import joinedload import uuid as uuid_module import io @@ -88,7 +88,22 @@ def get_stock_record(source_table, stock_id, for_update=False): return None -def get_stock_info(uuid_or_barcode): +def _filter_by_company(query, model, company_name): + """ + 对库存查询施加公司隔离。 + + 三张库存表都没有 company_id,公司维度挂在 material_base.company_name 上, + 故经由 base 关系做子查询过滤。 + + company_name 为 None 表示不过滤(超管/跨域用户未指定公司); + '__NO_COMPANY__' 是 get_current_company_filter() 的哨兵值,会匹配不到任何行。 + """ + if company_name is None: + return query + return query.filter(model.base.has(MaterialBase.company_name == company_name)) + + +def get_stock_info(uuid_or_barcode, company_name=None): """ 根据 uuid 或 barcode 查询库存信息(★ 精确匹配优先,性能与准确性兼顾) @@ -96,6 +111,10 @@ def get_stock_info(uuid_or_barcode): 在 SKU 前缀相同的场景会命中错误记录或漏匹配。 改为: 精确匹配(==)优先,命中即返回;无精确命中再回退模糊搜索。 + ★ 公司隔离: 不同公司可能存在相同 barcode/sku,不加公司条件时先被查到的 + 记录会「吃掉」条码,造成跨公司串货。company_name 由调用方经 + get_current_company_filter() 取得。 + 返回: (item, source_table, stock_id) 或 (None, None, None) """ # 清洗输入:去掉前后空格和换行符 @@ -124,7 +143,7 @@ def get_stock_info(uuid_or_barcode): for model, cond_fn, table_name in exact_checks: if not model: continue - item = model.query.filter(cond_fn(code)).first() + item = _filter_by_company(model.query, model, company_name).filter(cond_fn(code)).first() if item: return (item, table_name, item.id) @@ -149,7 +168,7 @@ def get_stock_info(uuid_or_barcode): for model, cond_fn, table_name in fuzzy_checks: if not model: continue - item = model.query.filter(cond_fn(code)).first() + item = _filter_by_company(model.query, model, company_name).filter(cond_fn(code)).first() if item: return (item, table_name, item.id) @@ -295,7 +314,6 @@ def _do_get_stock_list(permission_prefix=None): _strip_price_fields = _make_price_stripper(permission_prefix) # ★ 行级公司隔离:普通用户只能看到本公司的库存(超管/跨域不受限) - from app.utils.decorators import get_current_company_filter company_limit = get_current_company_filter() all_items = [] @@ -455,7 +473,7 @@ def scan_stock_by_barcode(): if not barcode: return jsonify({'code': 400, 'msg': 'barcode 不能为空'}), 400 - item, source_table, stock_id = get_stock_info(barcode) + item, source_table, stock_id = get_stock_info(barcode, get_current_company_filter()) if not item: return jsonify({'code': 404, 'msg': f'未找到该物料库存: {barcode}'}), 404 @@ -493,6 +511,9 @@ def get_drafts(): session_id = request.args.get('session_id') uuid = request.args.get('uuid', '', type=str) + # ★ 公司隔离:普通用户只能看到本公司的盘点草稿(超管/跨域不过滤) + company_name = get_current_company_filter() + # 防止 limit 过大(保持防御性上限,避免极端参数拖垮数据库) limit = min(max(limit, 1), 500) @@ -508,6 +529,9 @@ def get_drafts(): # ── 动态 WHERE 条件(全部参数绑定,防止 SQL 注入)── conditions = [] params = {} + if company_name is not None: + conditions.append('sd.company_name = :company') + params['company'] = company_name if session_id: conditions.append('sd.session_id = :sid') params['sid'] = session_id @@ -608,8 +632,11 @@ def add_draft(): if not session_id: session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}" + # ★ 公司隔离:按当前用户所属公司查库存,避免跨公司同码物料串货 + company_name = get_current_company_filter() + # 获取库存信息 - item, source_table, stock_id = get_stock_info(uuid) + item, source_table, stock_id = get_stock_info(uuid, company_name) if not item: return jsonify({"message": "未找到对应的库存记录"}), 404 @@ -629,10 +656,13 @@ def add_draft(): # 调整后的账面可用库存 = 账面总库存 - 借出未还数量 adjusted_stock_qty = stock_qty - total_borrowed - # ★ 查找是否已存在:按 (session_id, uuid) 去重,不按 user_id 隔离 + # ★ 查找是否已存在:按 (company_name, session_id, uuid) 去重,不按 user_id 隔离 # 修复: 同一盘点单多个用户(手机/平板不同账号)操作同一物料时, # 之前按 user_id 匹配导致每个用户各建一条 → 重复记录 - draft = StocktakeDraft.query.filter_by(uuid=uuid, session_id=session_id).first() + # ★ 公司隔离: 去重键必须含公司,否则跨公司同码物料会互相覆盖 + draft = StocktakeDraft.query.filter_by( + uuid=uuid, session_id=session_id, company_name=company_name + ).first() if draft: # 如果已存在,更新数量和时间 @@ -659,7 +689,9 @@ def add_draft(): source_table=source_table, stock_id=stock_id, # ★ 新增: 保存备注 - remark=remark.strip() if isinstance(remark, str) and remark else (remark if remark else None) + remark=remark.strip() if isinstance(remark, str) and remark else (remark if remark else None), + # ★ 公司隔离: 记录该草稿所属公司 + company_name=company_name ) db.session.add(draft) @@ -682,17 +714,22 @@ def add_draft(): def clear_draft(): """ 清除盘点草稿 - 支持清除指定 session_id 的记录,或清除所有记录 + + ★ 必须指定 session_id —— 禁止不传参全表清空(历史上会误删其他公司/用户的进度)。 + 清除范围同时受公司隔离约束。 """ - data = request.json + data = request.json or {} session_id = data.get('session_id') + if not session_id or not str(session_id).strip(): + return jsonify({"message": "session_id 不能为空"}), 400 + + company_name = get_current_company_filter() try: - query = StocktakeDraft.query - - if session_id: - # 清除指定会话 - query = query.filter_by(session_id=session_id) + # 清除指定会话(且限于本公司) + query = StocktakeDraft.query.filter_by(session_id=session_id) + if company_name is not None: + query = query.filter_by(company_name=company_name) # 改为对象级删除以触发审计事件 records = query.all() @@ -707,32 +744,106 @@ def clear_draft(): return jsonify({"message": str(e)}), 500 +@bp.route('/stocktake/companies', methods=['GET']) +@permission_required('inventory_stocktake') +def get_stocktake_companies(): + """ + 盘点页「公司选择器」的选项。 + + 单独开接口而不复用 /inbound/buy/options,原因有二: + 1. 后者要求 inbound_buy 权限,只做盘点的库管会 403; + 2. 后者返回全部公司名,而普通用户只需要看得到本公司(后端本来就会强制隔离)。 + + 返回: { companies: [str] } —— 超管拿到全部公司,普通用户只拿到本公司。 + """ + company_name = get_current_company_filter() + + try: + query = db.session.query(MaterialBase.company_name).filter( + MaterialBase.company_name.isnot(None), + MaterialBase.company_name != '' + ) + + # '__NO_COMPANY__' 是 get_current_company_filter 的哨兵值(用户未绑定公司), + # 拿它去匹配只会得到空列表 —— 这正是期望行为。 + if company_name is not None: + query = query.filter(MaterialBase.company_name == company_name) + + companies = sorted({r[0] for r in query.distinct().all()}) + + return jsonify({'code': 200, 'data': {'companies': companies}}), 200 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': str(e)}), 500 + + +@bp.route('/draft/active-session', methods=['GET']) +@permission_required('inventory_stocktake') +def get_active_session(): + """ + 获取当前公司最近一次活跃的盘点会话。 + + 多人多设备协同:PDA 进入盘点页时先问这个接口 —— 若本司已有同事开过的会话, + 直接加入该 session_id,而不是各开各的。 + + 「活跃」的定义:该公司所有草稿中 scan_time 最大的那个 session_id。 + 注意 /draft/list 是按 SKU 排序的,拿不到「最近会话」,故单列此接口。 + + 返回: { session_id: str|null, total: int, last_scan_time: str|null } + """ + 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, 'last_scan_time': None} + }), 200 + + return jsonify({ + 'code': 200, + 'data': { + 'session_id': row.session_id, + 'total': int(row.total or 0), + 'last_scan_time': row.last_scan_time.strftime('%Y-%m-%d %H:%M:%S') if row.last_scan_time else None + } + }), 200 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': str(e)}), 500 + + @bp.route('/draft/start-new', methods=['POST']) @permission_required('inventory_stocktake:operation') def start_new_session(): """ - 开始新一轮盘点 - 清空整张草稿表,返回新的 session_id + 开始新一轮盘点 —— 只签发新的 session_id,不删除任何历史数据。 + + ★ 改造:原实现执行 StocktakeDraft.query.all() 并逐条 delete, + 「开启新盘点」会清空**整张表**,抹掉其他公司、其他用户正在进行的盘点进度。 + 现改为纯签发:新旧会话靠 session_id 天然隔离,历史草稿原样保留, + 需要清理时走 /draft/clear(必须显式指定 session_id)。 """ - try: - # 清空整张草稿表(改为对象级删除以触发审计事件) - all_records = StocktakeDraft.query.all() - deleted_count = len(all_records) - for rec in all_records: - db.session.delete(rec) - db.session.commit() + new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}" - # 生成新的 session_id - new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}" - - return jsonify({ - "message": f"已清除 {deleted_count} 条旧记录", - "session_id": new_session_id, - "cleared_count": deleted_count - }), 200 - except Exception as e: - db.session.rollback() - return jsonify({"message": str(e)}), 500 + return jsonify({ + "message": "已开启新盘点会话(历史草稿保留)", + "session_id": new_session_id, + "cleared_count": 0 + }), 200 # --- 盘点结束与差异报告 --- @@ -759,10 +870,15 @@ def get_variance_report(): 返回所有有差异的记录(diff_qty != 0) """ session_id = request.args.get('session_id') + # ★ 公司隔离:普通用户只能看到本公司的差异(超管/跨域不过滤) + company_name = get_current_company_filter() try: query = StocktakeDraft.query + if company_name is not None: + query = query.filter_by(company_name=company_name) + if session_id: query = query.filter_by(session_id=session_id) @@ -827,15 +943,27 @@ def adjust_stock(): return jsonify({"message": "draft_id 或 stock_id 不能同时为空"}), 400 try: + # ★ 公司隔离:平账会写库存,绝不允许命中其他公司的草稿 + company_name = get_current_company_filter() + # 1. 尝试获取草稿 draft = StocktakeDraft.query.get(draft_id) if draft_id else None + if draft is not None and company_name is not None and draft.company_name != company_name: + # draft_id 属于其他公司 → 视为未命中,走后续兜底/无草稿分支 + draft = None if not draft and stock_id and source_table: - draft = StocktakeDraft.query.filter_by( + draft_query = StocktakeDraft.query.filter_by( stock_id=stock_id, source_table=source_table - ).first() + ) + if company_name is not None: + draft_query = draft_query.filter_by(company_name=company_name) + draft = draft_query.first() elif not draft and stock_id: - draft = StocktakeDraft.query.filter_by(stock_id=stock_id).first() + draft_query = StocktakeDraft.query.filter_by(stock_id=stock_id) + if company_name is not None: + draft_query = draft_query.filter_by(company_name=company_name) + draft = draft_query.first() # 2. 核心逻辑分支 if draft: @@ -978,6 +1106,8 @@ def export_stocktake(): try: # ★ 获取 session_id 参数,用于过滤当前会话的扫描记录 session_id = request.args.get('session_id', '', type=str) + # ★ 公司隔离:导出内容限定在当前用户所属公司 + company_name = get_current_company_filter() # 创建工作簿 wb = Workbook() @@ -1091,7 +1221,15 @@ def export_stocktake(): set_header_row(ws2, diff_headers) # 按 SKU 排序:先获取全部数据,再在 Python 中按 SKU 排序 - diff_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0).all() + # ★ 原依赖 /draft/start-new 清空整表,「全部草稿」恰好等价于当前会话; + # start-new 已改为不删数据,故必须显式按 session_id + 公司过滤, + # 否则会把历史会话、其他公司的盘点一起导出。 + diff_query = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0) + if session_id: + diff_query = diff_query.filter(StocktakeDraft.session_id == session_id) + if company_name is not None: + diff_query = diff_query.filter(StocktakeDraft.company_name == company_name) + diff_drafts = diff_query.all() diff_drafts_with_sku = [] for draft in diff_drafts: mat_info = get_material_info(draft.source_table, draft.stock_id) @@ -1132,7 +1270,13 @@ def export_stocktake(): set_header_row(ws3, normal_headers) # 按 SKU 排序 - normal_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty == 0).all() + # ★ 同上:显式按 session_id + 公司过滤 + normal_query = StocktakeDraft.query.filter(StocktakeDraft.diff_qty == 0) + if session_id: + normal_query = normal_query.filter(StocktakeDraft.session_id == session_id) + if company_name is not None: + normal_query = normal_query.filter(StocktakeDraft.company_name == company_name) + normal_drafts = normal_query.all() normal_drafts_with_sku = [] for draft in normal_drafts: mat_info = get_material_info(draft.source_table, draft.stock_id) @@ -1199,11 +1343,13 @@ def export_stocktake(): # 获取已盘点的 (source_table, stock_id) 集合 # ★ 修复:只查询当前 session_id 的扫描记录,避免历史记录干扰 + scanned_query = StocktakeDraft.query if session_id: - session_drafts = StocktakeDraft.query.filter_by(session_id=session_id).all() - else: - # 如果没有传 session_id,使用所有记录(兼容旧行为) - session_drafts = StocktakeDraft.query.all() + scanned_query = scanned_query.filter_by(session_id=session_id) + # ★ 公司隔离:无论是否传 session_id,都不得跨公司 + if company_name is not None: + scanned_query = scanned_query.filter_by(company_name=company_name) + session_drafts = scanned_query.all() scanned_set = {(d.source_table, d.stock_id) for d in session_drafts} # ★ 性能优化:批量预取所有未还借用的聚合数量 @@ -1222,7 +1368,7 @@ def export_stocktake(): unscanned_items = [] # ★ 修复 N+1 查询:使用 joinedload 预加载 base 关系,同时过滤 stock_quantity > 0 - for stock in StockBuy.query.filter(StockBuy.stock_quantity > 0).options(joinedload(StockBuy.base)).all(): + for stock in _filter_by_company(StockBuy.query, StockBuy, company_name).filter(StockBuy.stock_quantity > 0).options(joinedload(StockBuy.base)).all(): key = ('stock_buy', stock.id) if key in scanned_set: continue @@ -1256,7 +1402,7 @@ def export_stocktake(): # 遍历 StockSemi if StockSemi: - for stock in StockSemi.query.filter(StockSemi.stock_quantity > 0).options(joinedload(StockSemi.base)).all(): + for stock in _filter_by_company(StockSemi.query, StockSemi, company_name).filter(StockSemi.stock_quantity > 0).options(joinedload(StockSemi.base)).all(): key = ('stock_semi', stock.id) if key in scanned_set: continue @@ -1289,7 +1435,7 @@ def export_stocktake(): # 遍历 StockProduct if StockProduct: - for stock in StockProduct.query.filter(StockProduct.stock_quantity > 0).options(joinedload(StockProduct.base)).all(): + for stock in _filter_by_company(StockProduct.query, StockProduct, company_name).filter(StockProduct.stock_quantity > 0).options(joinedload(StockProduct.base)).all(): key = ('stock_product', stock.id) if key in scanned_set: continue @@ -1403,13 +1549,19 @@ def generate_missing_stocktake(): if not session_id: return jsonify({'code': 400, 'msg': '缺少 session_id 参数'}), 400 + # ★ 公司隔离:漏盘比对只在当前公司的库存与草稿范围内进行 + company_name = get_current_company_filter() + # ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录 # 特征:user_id == 'system' (表示由系统自动生成) # 改为对象级删除以触发审计事件 - system_records = StocktakeDraft.query.filter( + system_query = StocktakeDraft.query.filter( StocktakeDraft.session_id == session_id, StocktakeDraft.user_id == 'system' - ).all() + ) + if company_name is not None: + system_query = system_query.filter(StocktakeDraft.company_name == company_name) + system_records = system_query.all() deleted_count = len(system_records) for rec in system_records: db.session.delete(rec) @@ -1418,10 +1570,13 @@ def generate_missing_stocktake(): print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录") # 1. 获取当前会话已有盘点记录的 (source_table, stock_id) 集合 - existing_records = db.session.query( + existing_query = db.session.query( StocktakeDraft.source_table, StocktakeDraft.stock_id - ).filter(StocktakeDraft.session_id == session_id).distinct().all() + ).filter(StocktakeDraft.session_id == session_id) + if company_name is not None: + existing_query = existing_query.filter(StocktakeDraft.company_name == company_name) + existing_records = existing_query.distinct().all() scanned_keys = set() for src_table, stock_id in existing_records: @@ -1432,7 +1587,7 @@ def generate_missing_stocktake(): all_stock = [] # 采购库存 - for item in StockBuy.query.filter(StockBuy.stock_quantity > 0).all(): + for item in _filter_by_company(StockBuy.query, StockBuy, company_name).filter(StockBuy.stock_quantity > 0).all(): all_stock.append({ 'source_table': 'stock_buy', 'stock_id': item.id, @@ -1442,7 +1597,7 @@ def generate_missing_stocktake(): # 半成品库存 if StockSemi: - for item in StockSemi.query.filter(StockSemi.stock_quantity > 0).all(): + for item in _filter_by_company(StockSemi.query, StockSemi, company_name).filter(StockSemi.stock_quantity > 0).all(): all_stock.append({ 'source_table': 'stock_semi', 'stock_id': item.id, @@ -1452,7 +1607,7 @@ def generate_missing_stocktake(): # 成品库存 if StockProduct: - for item in StockProduct.query.filter(StockProduct.stock_quantity > 0).all(): + for item in _filter_by_company(StockProduct.query, StockProduct, company_name).filter(StockProduct.stock_quantity > 0).all(): all_stock.append({ 'source_table': 'stock_product', 'stock_id': item.id, @@ -1476,7 +1631,8 @@ def generate_missing_stocktake(): stock_id=stock['stock_id'], stock_qty=stock['stock_qty'], diff_qty=-stock['stock_qty'], # 差异 = 0 - 库存数 = 负数 - remark='未盘点到,系统自动标记为盘亏' + remark='未盘点到,系统自动标记为盘亏', + company_name=company_name # ★ 公司隔离 ) db.session.add(draft) missing_count += 1 @@ -1517,6 +1673,12 @@ def get_draft_merged_list(): page = max(request.args.get('page', 1, type=int), 1) page_size = min(request.args.get('pageSize', 20, type=int), 200) + # ★ 公司隔离:盘点基数(库存侧)与已扫草稿(草稿侧)都限定在当前公司。 + # 超管/跨域用户 company_name 为 None,不加任何公司条件。 + company_name = get_current_company_filter() + company_cond_sd = ' AND sd.company_name = :company' if company_name is not None else '' + company_cond_bare = ' AND company_name = :company' if company_name is not None else '' + # ── 公共 CTE / 子查询片段 ── union_sql = """ SELECT id, 'stock_buy' AS source_table, sku, @@ -1536,6 +1698,10 @@ def get_draft_merged_list(): conditions = [] params = {'sid': session_id} + if company_name is not None: + conditions.append("mb.company_name = :company") + params['company'] = company_name + if keyword: conditions.append("(LOWER(cs.sku) LIKE :kw OR LOWER(mb.name) LIKE :kw)") params['kw'] = f'%{keyword}%' @@ -1554,17 +1720,21 @@ def get_draft_merged_list(): ) cs LEFT JOIN material_base mb ON cs.base_id = mb.id LEFT JOIN stocktake_draft sd ON sd.source_table = cs.source_table - AND sd.stock_id = cs.id AND sd.session_id = :sid + AND sd.stock_id = cs.id AND sd.session_id = :sid{company_cond_sd} {where_clause} """ total = db.session.execute(db.text(count_sql), params).scalar() # ── 已扫数量(去重) ── - scanned_sql = """ + scanned_sql = f""" SELECT COUNT(DISTINCT (source_table, stock_id)) - FROM stocktake_draft WHERE session_id = :sid + FROM stocktake_draft WHERE session_id = :sid{company_cond_bare} """ - total_scanned = db.session.execute(db.text(scanned_sql), {'sid': session_id}).scalar() or 0 + # 该语句只引用 :sid / :company,单独构造参数避免夹带未引用的 :kw + scanned_params = {'sid': session_id} + if company_name is not None: + scanned_params['company'] = company_name + total_scanned = db.session.execute(db.text(scanned_sql), scanned_params).scalar() or 0 # ── 数据查询(LIMIT/OFFSET) ── offset = (page - 1) * page_size @@ -1580,7 +1750,7 @@ def get_draft_merged_list(): ) cs LEFT JOIN material_base mb ON cs.base_id = mb.id LEFT JOIN stocktake_draft sd ON sd.source_table = cs.source_table - AND sd.stock_id = cs.id AND sd.session_id = :sid + AND sd.stock_id = cs.id AND sd.session_id = :sid{company_cond_sd} {where_clause} ORDER BY cs.sku LIMIT :limit OFFSET :offset @@ -1643,6 +1813,10 @@ def get_all_stocktake_items(): page = max(1, request.args.get('page', 1, type=int)) pageSize = min(200, max(1, request.args.get('pageSize', 50, type=int))) + # ★ 公司隔离:盘点基数只统计本公司库存(超管/跨域不过滤) + company_name = get_current_company_filter() + company_cond_bare = ' AND company_name = :company' if company_name is not None else '' + # ── 原生 SQL:UNION ALL 三张库存表 + LEFT JOIN material_base,数据库级分页 ── # 注意:stock_product 表没有 batch_number 列,故第三个分支直接用 serial_number。 union_sql = """ @@ -1665,6 +1839,9 @@ def get_all_stocktake_items(): # ── 动态 WHERE(SKU / 物料名 / 规格 模糊搜索)── conditions = [] params = {} + if company_name is not None: + conditions.append("mb.company_name = :company") + params['company'] = company_name if keyword: conditions.append("(LOWER(cs.sku) LIKE :kw OR LOWER(mb.name) LIKE :kw OR LOWER(mb.spec_model) LIKE :kw)") params['kw'] = f'%{keyword.lower()}%' @@ -1715,12 +1892,15 @@ def get_all_stocktake_items(): session_id = request.args.get('session_id', '', type=str) total_scanned = 0 if session_id: - scanned_sql = """ + scanned_sql = f""" SELECT COUNT(DISTINCT (source_table, stock_id)) - FROM stocktake_draft WHERE session_id = :sid + FROM stocktake_draft WHERE session_id = :sid{company_cond_bare} """ + scanned_params = {'sid': session_id} + if company_name is not None: + scanned_params['company'] = company_name total_scanned = db.session.execute( - db.text(scanned_sql), {'sid': session_id} + db.text(scanned_sql), scanned_params ).scalar() or 0 return jsonify({ @@ -1761,6 +1941,9 @@ def update_stocktake_quantity(): if not stock_id or not source_table: return jsonify({'code': 400, 'msg': '缺少必要参数'}), 400 + # ★ 公司隔离:只能改本公司的盘点记录 + company_name = get_current_company_filter() + # 查找对应的盘点记录(★ 修复:限定 session_id,避免不同盘点会话相互覆盖) query = StocktakeDraft.query.filter_by( stock_id=stock_id, @@ -1768,6 +1951,8 @@ def update_stocktake_quantity(): ) if session_id: query = query.filter_by(session_id=session_id) + if company_name is not None: + query = query.filter_by(company_name=company_name) draft = query.first() if not draft: