fix(stocktake): 盘点链路按公司隔离,开启新盘点不再清空整表

修复三处跨公司数据污染:

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  该公司最近一次活跃会话,供多设备加入
This commit is contained in:
yueli
2026-09-11 11:33:12 +08:00
parent 6586d3cb75
commit b8d18c71d8

View File

@ -2,7 +2,7 @@ from flask import Blueprint, jsonify, request, send_file, current_app
from app.extensions import db, beijing_time from app.extensions import db, beijing_time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity 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 from sqlalchemy.orm import joinedload
import uuid as uuid_module import uuid as uuid_module
import io import io
@ -88,7 +88,22 @@ def get_stock_record(source_table, stock_id, for_update=False):
return None 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 查询库存信息(★ 精确匹配优先,性能与准确性兼顾) 根据 uuid 或 barcode 查询库存信息(★ 精确匹配优先,性能与准确性兼顾)
@ -96,6 +111,10 @@ def get_stock_info(uuid_or_barcode):
在 SKU 前缀相同的场景会命中错误记录或漏匹配。 在 SKU 前缀相同的场景会命中错误记录或漏匹配。
改为: 精确匹配(==)优先,命中即返回;无精确命中再回退模糊搜索。 改为: 精确匹配(==)优先,命中即返回;无精确命中再回退模糊搜索。
★ 公司隔离: 不同公司可能存在相同 barcode/sku不加公司条件时先被查到的
记录会「吃掉」条码造成跨公司串货。company_name 由调用方经
get_current_company_filter() 取得。
返回: (item, source_table, stock_id) 或 (None, None, None) 返回: (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: for model, cond_fn, table_name in exact_checks:
if not model: if not model:
continue 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: if item:
return (item, table_name, item.id) 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: for model, cond_fn, table_name in fuzzy_checks:
if not model: if not model:
continue 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: if item:
return (item, table_name, item.id) 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) _strip_price_fields = _make_price_stripper(permission_prefix)
# ★ 行级公司隔离:普通用户只能看到本公司的库存(超管/跨域不受限) # ★ 行级公司隔离:普通用户只能看到本公司的库存(超管/跨域不受限)
from app.utils.decorators import get_current_company_filter
company_limit = get_current_company_filter() company_limit = get_current_company_filter()
all_items = [] all_items = []
@ -455,7 +473,7 @@ def scan_stock_by_barcode():
if not barcode: if not barcode:
return jsonify({'code': 400, 'msg': 'barcode 不能为空'}), 400 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: if not item:
return jsonify({'code': 404, 'msg': f'未找到该物料库存: {barcode}'}), 404 return jsonify({'code': 404, 'msg': f'未找到该物料库存: {barcode}'}), 404
@ -493,6 +511,9 @@ def get_drafts():
session_id = request.args.get('session_id') session_id = request.args.get('session_id')
uuid = request.args.get('uuid', '', type=str) uuid = request.args.get('uuid', '', type=str)
# ★ 公司隔离:普通用户只能看到本公司的盘点草稿(超管/跨域不过滤)
company_name = get_current_company_filter()
# 防止 limit 过大(保持防御性上限,避免极端参数拖垮数据库) # 防止 limit 过大(保持防御性上限,避免极端参数拖垮数据库)
limit = min(max(limit, 1), 500) limit = min(max(limit, 1), 500)
@ -508,6 +529,9 @@ def get_drafts():
# ── 动态 WHERE 条件(全部参数绑定,防止 SQL 注入)── # ── 动态 WHERE 条件(全部参数绑定,防止 SQL 注入)──
conditions = [] conditions = []
params = {} params = {}
if company_name is not None:
conditions.append('sd.company_name = :company')
params['company'] = company_name
if session_id: if session_id:
conditions.append('sd.session_id = :sid') conditions.append('sd.session_id = :sid')
params['sid'] = session_id params['sid'] = session_id
@ -608,8 +632,11 @@ def add_draft():
if not session_id: if not session_id:
session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}" 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: if not item:
return jsonify({"message": "未找到对应的库存记录"}), 404 return jsonify({"message": "未找到对应的库存记录"}), 404
@ -629,10 +656,13 @@ def add_draft():
# 调整后的账面可用库存 = 账面总库存 - 借出未还数量 # 调整后的账面可用库存 = 账面总库存 - 借出未还数量
adjusted_stock_qty = stock_qty - total_borrowed adjusted_stock_qty = stock_qty - total_borrowed
# ★ 查找是否已存在:按 (session_id, uuid) 去重,不按 user_id 隔离 # ★ 查找是否已存在:按 (company_name, session_id, uuid) 去重,不按 user_id 隔离
# 修复: 同一盘点单多个用户(手机/平板不同账号)操作同一物料时, # 修复: 同一盘点单多个用户(手机/平板不同账号)操作同一物料时,
# 之前按 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: if draft:
# 如果已存在,更新数量和时间 # 如果已存在,更新数量和时间
@ -659,7 +689,9 @@ def add_draft():
source_table=source_table, source_table=source_table,
stock_id=stock_id, 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) db.session.add(draft)
@ -682,17 +714,22 @@ def add_draft():
def clear_draft(): def clear_draft():
""" """
清除盘点草稿 清除盘点草稿
支持清除指定 session_id 的记录,或清除所有记录
★ 必须指定 session_id —— 禁止不传参全表清空(历史上会误删其他公司/用户的进度)。
清除范围同时受公司隔离约束。
""" """
data = request.json data = request.json or {}
session_id = data.get('session_id') 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: try:
query = StocktakeDraft.query # 清除指定会话(且限于本公司)
query = StocktakeDraft.query.filter_by(session_id=session_id)
if session_id: if company_name is not None:
# 清除指定会话 query = query.filter_by(company_name=company_name)
query = query.filter_by(session_id=session_id)
# 改为对象级删除以触发审计事件 # 改为对象级删除以触发审计事件
records = query.all() records = query.all()
@ -707,32 +744,106 @@ def clear_draft():
return jsonify({"message": str(e)}), 500 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']) @bp.route('/draft/start-new', methods=['POST'])
@permission_required('inventory_stocktake:operation') @permission_required('inventory_stocktake:operation')
def start_new_session(): def start_new_session():
""" """
开始新一轮盘点 开始新一轮盘点 —— 只签发新的 session_id不删除任何历史数据。
清空整张草稿表,返回新的 session_id
★ 改造:原实现执行 StocktakeDraft.query.all() 并逐条 delete
「开启新盘点」会清空**整张表**,抹掉其他公司、其他用户正在进行的盘点进度。
现改为纯签发:新旧会话靠 session_id 天然隔离,历史草稿原样保留,
需要清理时走 /draft/clear必须显式指定 session_id
""" """
try: new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}"
# 清空整张草稿表(改为对象级删除以触发审计事件)
all_records = StocktakeDraft.query.all()
deleted_count = len(all_records)
for rec in all_records:
db.session.delete(rec)
db.session.commit()
# 生成新的 session_id return jsonify({
new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}" "message": "已开启新盘点会话(历史草稿保留)",
"session_id": new_session_id,
return jsonify({ "cleared_count": 0
"message": f"已清除 {deleted_count} 条旧记录", }), 200
"session_id": new_session_id,
"cleared_count": deleted_count
}), 200
except Exception as e:
db.session.rollback()
return jsonify({"message": str(e)}), 500
# --- 盘点结束与差异报告 --- # --- 盘点结束与差异报告 ---
@ -759,10 +870,15 @@ def get_variance_report():
返回所有有差异的记录diff_qty != 0 返回所有有差异的记录diff_qty != 0
""" """
session_id = request.args.get('session_id') session_id = request.args.get('session_id')
# ★ 公司隔离:普通用户只能看到本公司的差异(超管/跨域不过滤)
company_name = get_current_company_filter()
try: try:
query = StocktakeDraft.query query = StocktakeDraft.query
if company_name is not None:
query = query.filter_by(company_name=company_name)
if session_id: if session_id:
query = query.filter_by(session_id=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 return jsonify({"message": "draft_id 或 stock_id 不能同时为空"}), 400
try: try:
# ★ 公司隔离:平账会写库存,绝不允许命中其他公司的草稿
company_name = get_current_company_filter()
# 1. 尝试获取草稿 # 1. 尝试获取草稿
draft = StocktakeDraft.query.get(draft_id) if draft_id else None 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: if not draft and stock_id and source_table:
draft = StocktakeDraft.query.filter_by( draft_query = StocktakeDraft.query.filter_by(
stock_id=stock_id, stock_id=stock_id,
source_table=source_table 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: 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. 核心逻辑分支 # 2. 核心逻辑分支
if draft: if draft:
@ -978,6 +1106,8 @@ def export_stocktake():
try: try:
# ★ 获取 session_id 参数,用于过滤当前会话的扫描记录 # ★ 获取 session_id 参数,用于过滤当前会话的扫描记录
session_id = request.args.get('session_id', '', type=str) session_id = request.args.get('session_id', '', type=str)
# ★ 公司隔离:导出内容限定在当前用户所属公司
company_name = get_current_company_filter()
# 创建工作簿 # 创建工作簿
wb = Workbook() wb = Workbook()
@ -1091,7 +1221,15 @@ def export_stocktake():
set_header_row(ws2, diff_headers) set_header_row(ws2, diff_headers)
# 按 SKU 排序:先获取全部数据,再在 Python 中按 SKU 排序 # 按 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 = [] diff_drafts_with_sku = []
for draft in diff_drafts: for draft in diff_drafts:
mat_info = get_material_info(draft.source_table, draft.stock_id) mat_info = get_material_info(draft.source_table, draft.stock_id)
@ -1132,7 +1270,13 @@ def export_stocktake():
set_header_row(ws3, normal_headers) set_header_row(ws3, normal_headers)
# 按 SKU 排序 # 按 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 = [] normal_drafts_with_sku = []
for draft in normal_drafts: for draft in normal_drafts:
mat_info = get_material_info(draft.source_table, draft.stock_id) mat_info = get_material_info(draft.source_table, draft.stock_id)
@ -1199,11 +1343,13 @@ def export_stocktake():
# 获取已盘点的 (source_table, stock_id) 集合 # 获取已盘点的 (source_table, stock_id) 集合
# ★ 修复:只查询当前 session_id 的扫描记录,避免历史记录干扰 # ★ 修复:只查询当前 session_id 的扫描记录,避免历史记录干扰
scanned_query = StocktakeDraft.query
if session_id: if session_id:
session_drafts = StocktakeDraft.query.filter_by(session_id=session_id).all() scanned_query = scanned_query.filter_by(session_id=session_id)
else: # ★ 公司隔离:无论是否传 session_id都不得跨公司
# 如果没有传 session_id使用所有记录兼容旧行为 if company_name is not None:
session_drafts = StocktakeDraft.query.all() 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} scanned_set = {(d.source_table, d.stock_id) for d in session_drafts}
# ★ 性能优化:批量预取所有未还借用的聚合数量 # ★ 性能优化:批量预取所有未还借用的聚合数量
@ -1222,7 +1368,7 @@ def export_stocktake():
unscanned_items = [] unscanned_items = []
# ★ 修复 N+1 查询:使用 joinedload 预加载 base 关系,同时过滤 stock_quantity > 0 # ★ 修复 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) key = ('stock_buy', stock.id)
if key in scanned_set: if key in scanned_set:
continue continue
@ -1256,7 +1402,7 @@ def export_stocktake():
# 遍历 StockSemi # 遍历 StockSemi
if 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) key = ('stock_semi', stock.id)
if key in scanned_set: if key in scanned_set:
continue continue
@ -1289,7 +1435,7 @@ def export_stocktake():
# 遍历 StockProduct # 遍历 StockProduct
if 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) key = ('stock_product', stock.id)
if key in scanned_set: if key in scanned_set:
continue continue
@ -1403,13 +1549,19 @@ def generate_missing_stocktake():
if not session_id: if not session_id:
return jsonify({'code': 400, 'msg': '缺少 session_id 参数'}), 400 return jsonify({'code': 400, 'msg': '缺少 session_id 参数'}), 400
# ★ 公司隔离:漏盘比对只在当前公司的库存与草稿范围内进行
company_name = get_current_company_filter()
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录 # ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
# 特征user_id == 'system' (表示由系统自动生成) # 特征user_id == 'system' (表示由系统自动生成)
# 改为对象级删除以触发审计事件 # 改为对象级删除以触发审计事件
system_records = StocktakeDraft.query.filter( system_query = StocktakeDraft.query.filter(
StocktakeDraft.session_id == session_id, StocktakeDraft.session_id == session_id,
StocktakeDraft.user_id == 'system' 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) deleted_count = len(system_records)
for rec in system_records: for rec in system_records:
db.session.delete(rec) db.session.delete(rec)
@ -1418,10 +1570,13 @@ def generate_missing_stocktake():
print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录") print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录")
# 1. 获取当前会话已有盘点记录的 (source_table, stock_id) 集合 # 1. 获取当前会话已有盘点记录的 (source_table, stock_id) 集合
existing_records = db.session.query( existing_query = db.session.query(
StocktakeDraft.source_table, StocktakeDraft.source_table,
StocktakeDraft.stock_id 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() scanned_keys = set()
for src_table, stock_id in existing_records: for src_table, stock_id in existing_records:
@ -1432,7 +1587,7 @@ def generate_missing_stocktake():
all_stock = [] 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({ all_stock.append({
'source_table': 'stock_buy', 'source_table': 'stock_buy',
'stock_id': item.id, 'stock_id': item.id,
@ -1442,7 +1597,7 @@ def generate_missing_stocktake():
# 半成品库存 # 半成品库存
if StockSemi: 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({ all_stock.append({
'source_table': 'stock_semi', 'source_table': 'stock_semi',
'stock_id': item.id, 'stock_id': item.id,
@ -1452,7 +1607,7 @@ def generate_missing_stocktake():
# 成品库存 # 成品库存
if StockProduct: 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({ all_stock.append({
'source_table': 'stock_product', 'source_table': 'stock_product',
'stock_id': item.id, 'stock_id': item.id,
@ -1476,7 +1631,8 @@ def generate_missing_stocktake():
stock_id=stock['stock_id'], stock_id=stock['stock_id'],
stock_qty=stock['stock_qty'], stock_qty=stock['stock_qty'],
diff_qty=-stock['stock_qty'], # 差异 = 0 - 库存数 = 负数 diff_qty=-stock['stock_qty'], # 差异 = 0 - 库存数 = 负数
remark='未盘点到,系统自动标记为盘亏' remark='未盘点到,系统自动标记为盘亏',
company_name=company_name # ★ 公司隔离
) )
db.session.add(draft) db.session.add(draft)
missing_count += 1 missing_count += 1
@ -1517,6 +1673,12 @@ def get_draft_merged_list():
page = max(request.args.get('page', 1, type=int), 1) page = max(request.args.get('page', 1, type=int), 1)
page_size = min(request.args.get('pageSize', 20, type=int), 200) 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 / 子查询片段 ── # ── 公共 CTE / 子查询片段 ──
union_sql = """ union_sql = """
SELECT id, 'stock_buy' AS source_table, sku, SELECT id, 'stock_buy' AS source_table, sku,
@ -1536,6 +1698,10 @@ def get_draft_merged_list():
conditions = [] conditions = []
params = {'sid': session_id} params = {'sid': session_id}
if company_name is not None:
conditions.append("mb.company_name = :company")
params['company'] = company_name
if keyword: if keyword:
conditions.append("(LOWER(cs.sku) LIKE :kw OR LOWER(mb.name) LIKE :kw)") conditions.append("(LOWER(cs.sku) LIKE :kw OR LOWER(mb.name) LIKE :kw)")
params['kw'] = f'%{keyword}%' params['kw'] = f'%{keyword}%'
@ -1554,17 +1720,21 @@ def get_draft_merged_list():
) cs ) cs
LEFT JOIN material_base mb ON cs.base_id = mb.id LEFT JOIN material_base mb ON cs.base_id = mb.id
LEFT JOIN stocktake_draft sd ON sd.source_table = cs.source_table 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} {where_clause}
""" """
total = db.session.execute(db.text(count_sql), params).scalar() total = db.session.execute(db.text(count_sql), params).scalar()
# ── 已扫数量(去重) ── # ── 已扫数量(去重) ──
scanned_sql = """ scanned_sql = f"""
SELECT COUNT(DISTINCT (source_table, stock_id)) 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 ── # ── 数据查询LIMIT/OFFSET ──
offset = (page - 1) * page_size offset = (page - 1) * page_size
@ -1580,7 +1750,7 @@ def get_draft_merged_list():
) cs ) cs
LEFT JOIN material_base mb ON cs.base_id = mb.id LEFT JOIN material_base mb ON cs.base_id = mb.id
LEFT JOIN stocktake_draft sd ON sd.source_table = cs.source_table 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} {where_clause}
ORDER BY cs.sku ORDER BY cs.sku
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
@ -1643,6 +1813,10 @@ def get_all_stocktake_items():
page = max(1, request.args.get('page', 1, type=int)) page = max(1, request.args.get('page', 1, type=int))
pageSize = min(200, max(1, request.args.get('pageSize', 50, 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 ''
# ── 原生 SQLUNION ALL 三张库存表 + LEFT JOIN material_base数据库级分页 ── # ── 原生 SQLUNION ALL 三张库存表 + LEFT JOIN material_base数据库级分页 ──
# 注意stock_product 表没有 batch_number 列,故第三个分支直接用 serial_number。 # 注意stock_product 表没有 batch_number 列,故第三个分支直接用 serial_number。
union_sql = """ union_sql = """
@ -1665,6 +1839,9 @@ def get_all_stocktake_items():
# ── 动态 WHERESKU / 物料名 / 规格 模糊搜索)── # ── 动态 WHERESKU / 物料名 / 规格 模糊搜索)──
conditions = [] conditions = []
params = {} params = {}
if company_name is not None:
conditions.append("mb.company_name = :company")
params['company'] = company_name
if keyword: if keyword:
conditions.append("(LOWER(cs.sku) LIKE :kw OR LOWER(mb.name) LIKE :kw OR LOWER(mb.spec_model) LIKE :kw)") 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()}%' params['kw'] = f'%{keyword.lower()}%'
@ -1715,12 +1892,15 @@ def get_all_stocktake_items():
session_id = request.args.get('session_id', '', type=str) session_id = request.args.get('session_id', '', type=str)
total_scanned = 0 total_scanned = 0
if session_id: if session_id:
scanned_sql = """ scanned_sql = f"""
SELECT COUNT(DISTINCT (source_table, stock_id)) 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( total_scanned = db.session.execute(
db.text(scanned_sql), {'sid': session_id} db.text(scanned_sql), scanned_params
).scalar() or 0 ).scalar() or 0
return jsonify({ return jsonify({
@ -1761,6 +1941,9 @@ def update_stocktake_quantity():
if not stock_id or not source_table: if not stock_id or not source_table:
return jsonify({'code': 400, 'msg': '缺少必要参数'}), 400 return jsonify({'code': 400, 'msg': '缺少必要参数'}), 400
# ★ 公司隔离:只能改本公司的盘点记录
company_name = get_current_company_filter()
# 查找对应的盘点记录(★ 修复:限定 session_id避免不同盘点会话相互覆盖 # 查找对应的盘点记录(★ 修复:限定 session_id避免不同盘点会话相互覆盖
query = StocktakeDraft.query.filter_by( query = StocktakeDraft.query.filter_by(
stock_id=stock_id, stock_id=stock_id,
@ -1768,6 +1951,8 @@ def update_stocktake_quantity():
) )
if session_id: if session_id:
query = query.filter_by(session_id=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() draft = query.first()
if not draft: if not draft: