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:
@ -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 ''
|
||||||
|
|
||||||
# ── 原生 SQL:UNION ALL 三张库存表 + LEFT JOIN material_base,数据库级分页 ──
|
# ── 原生 SQL:UNION 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():
|
|||||||
# ── 动态 WHERE(SKU / 物料名 / 规格 模糊搜索)──
|
# ── 动态 WHERE(SKU / 物料名 / 规格 模糊搜索)──
|
||||||
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user