Files
KCGL/inventory-backend/app/api/v1/inbound/stock.py
yueli 02e03e6fb6 fix(stocktake): 补上模块级 traceback 导入,修复异常分支的 NameError
stock.py 模块级从未 import traceback,却有 3 处裸调 traceback.print_exc():

- scan_stock_by_barcode (/scan) —— 本次改动之前就存在
- get_stocktake_companies (新增)
- get_active_session    (新增)

后果是真正的错误被 NameError 盖住:例如 company_name 列不存在时,
日志里只剩「name 'traceback' is not defined」,看不到根因。

其余 4 处在 except 块内写了局部 import 因而一直正常,现统一由模块级提供。
2026-09-11 11:34:23 +08:00

1978 lines
86 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, get_current_company_filter
from sqlalchemy.orm import joinedload
import uuid as uuid_module
import io
import traceback
from openpyxl import Workbook
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.transaction import TransBorrow
from app.models.base import MaterialBase
# 尝试导入用户模型
try:
from app.models.system import SysUser
except ImportError:
SysUser = None
# 尝试导入半成品和成品
import logging
try:
from app.models.inbound.semi import StockSemi
except Exception as e:
logging.error(f"❌ 致命错误StockSemi 模型导入失败: {e}")
StockSemi = None
try:
from app.models.inbound.product import StockProduct
except Exception as e:
logging.error(f"❌ 致命错误StockProduct 模型导入失败: {e}")
StockProduct = None
def _normalize_user_id(user_id=None):
"""规范化 user_id确保是有效字符串"""
# 优先使用传入的 user_id否则从 JWT 获取
if user_id and isinstance(user_id, str) and len(user_id) <= 100:
return user_id.strip()
# 从 JWT 获取当前用户
try:
return get_jwt().get('display_name') or get_jwt_identity()
except:
return 'unknown'
def get_stock_model(source_table):
"""根据source_table获取对应的库存模型"""
if source_table == 'stock_buy':
return StockBuy
elif source_table == 'stock_semi':
return StockSemi
elif source_table == 'stock_product':
return StockProduct
return None
from app.services.print.network_print_service import NetworkPrintService
bp = Blueprint('stock_ops', __name__)
# ============================================================
# 辅助函数:获取库存记录
# ============================================================
def get_stock_record(source_table, stock_id, for_update=False):
"""根据库存类型和ID获取库存记录
Args:
source_table: 库存类型 ('stock_buy' / 'stock_semi' / 'stock_product')
stock_id: 库存记录主键ID
for_update: 是否使用 SELECT ... FOR UPDATE 悲观行锁(默认 False
设为 True 时可防止并发调整/报废导致的 TOCTOU 竞态
"""
if source_table == 'stock_buy' and StockBuy:
q = StockBuy.query
return (q.with_for_update() if for_update else q).get(stock_id)
elif source_table == 'stock_semi' and StockSemi:
q = StockSemi.query
return (q.with_for_update() if for_update else q).get(stock_id)
elif source_table == 'stock_product' and StockProduct:
q = StockProduct.query
return (q.with_for_update() if for_update else q).get(stock_id)
return None
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 查询库存信息(★ 精确匹配优先,性能与准确性兼顾)
修复: 原来用 ilike %x% 全表模糊搜索 + .first()
在 SKU 前缀相同的场景会命中错误记录或漏匹配。
改为: 精确匹配(==)优先,命中即返回;无精确命中再回退模糊搜索。
★ 公司隔离: 不同公司可能存在相同 barcode/sku不加公司条件时先被查到的
记录会「吃掉」条码造成跨公司串货。company_name 由调用方经
get_current_company_filter() 取得。
返回: (item, source_table, stock_id) 或 (None, None, None)
"""
# 清洗输入:去掉前后空格和换行符
code = str(uuid_or_barcode).strip()
if not code:
return None, None, None
# ===== 精确匹配优先(走索引,快且准) =====
exact_checks = [
(StockProduct, lambda c: db.or_(
StockProduct.barcode == c,
StockProduct.sku == c,
StockProduct.serial_number == c
), 'stock_product'),
(StockSemi, lambda c: db.or_(
StockSemi.barcode == c,
StockSemi.sku == c,
StockSemi.serial_number == c
), 'stock_semi'),
(StockBuy, lambda c: db.or_(
StockBuy.barcode == c,
StockBuy.sku == c
), 'stock_buy'),
]
for model, cond_fn, table_name in exact_checks:
if not model:
continue
item = _filter_by_company(model.query, model, company_name).filter(cond_fn(code)).first()
if item:
return (item, table_name, item.id)
# ===== 精确未命中 → 回退模糊搜索(保留旧行为兜底) =====
fuzzy_checks = [
(StockProduct, lambda c: db.or_(
StockProduct.barcode.ilike(f"%{c}%"),
StockProduct.sku.ilike(f"%{c}%"),
StockProduct.serial_number.ilike(f"%{c}%")
), 'stock_product'),
(StockSemi, lambda c: db.or_(
StockSemi.barcode.ilike(f"%{c}%"),
StockSemi.sku.ilike(f"%{c}%"),
StockSemi.serial_number.ilike(f"%{c}%")
), 'stock_semi'),
(StockBuy, lambda c: db.or_(
StockBuy.barcode.ilike(f"%{c}%"),
StockBuy.sku.ilike(f"%{c}%")
), 'stock_buy'),
]
for model, cond_fn, table_name in fuzzy_checks:
if not model:
continue
item = _filter_by_company(model.query, model, company_name).filter(cond_fn(code)).first()
if item:
return (item, table_name, item.id)
return None, None, None
return (None, None, None)
@bp.route('/all', methods=['GET'])
@jwt_required()
def get_all_stock():
"""
获取所有库存 > 0 的物品
支持 AI 极简模式: ?ai_mode=true
- 只返回 name / spec / availableQuantity 三个字段
- 键名压缩为 n / s / c
★ Fail-Closed: 非 AI 模式自动剥离所有价格成本字段
"""
ai_mode = request.args.get('ai_mode', '').lower() == 'true'
_strip = _make_price_stripper('inventory_stocktake')
try:
all_items = []
# 1. 采购件
if StockBuy:
rows = StockBuy.query.filter(
StockBuy.stock_quantity > 0
).options(joinedload(StockBuy.base)).all()
for item in rows:
if ai_mode:
b = item.base
all_items.append({
'n': b.name if b else '',
's': b.spec_model if b else '',
'c': float(item.available_quantity or 0)
})
else:
d = item.to_dict()
_strip(d, 'buy')
all_items.append(d)
# 2. 半成品
if StockSemi:
try:
rows = StockSemi.query.filter(
StockSemi.stock_quantity > 0
).options(joinedload(StockSemi.base)).all()
for item in rows:
if ai_mode:
b = item.base
all_items.append({
'n': b.name if b else '',
's': b.spec_model if b else '',
'c': float(item.available_quantity or 0)
})
else:
d = item.to_dict()
_strip(d, 'semi')
all_items.append(d)
except Exception:
pass
# 3. 成品
if StockProduct:
try:
rows = StockProduct.query.filter(
StockProduct.stock_quantity > 0
).options(joinedload(StockProduct.base)).all()
for item in rows:
if ai_mode:
b = item.base
all_items.append({
'n': b.name if b else '',
's': b.spec_model if b else '',
'c': float(item.available_quantity or 0)
})
else:
d = item.to_dict()
_strip(d, 'product')
all_items.append(d)
except Exception:
pass
return jsonify(all_items), 200
except Exception as e:
print(f"Error: {e}")
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500
# ==============================================================================
# 分页库存查询接口(服务端分页,出库/盘点/借用模块共用)
# ==============================================================================
def _make_price_stripper(permission_prefix=None):
"""
Fail-Closed 价格字段剥离器工厂。
选单/借用场景默认剥离所有价格字段;
仅当 permission_prefix 不在已知选单前缀列表中时放行(例如 material_list 场景由调用方自行处理)。
"""
# 已知的选单/借用前缀 → 无条件剥离所有价格成本
# ★ 新增前缀必须登记在此,否则该前缀的调用方会拿到价格/成本字段Fail-Closed 失效)
SELECTION_PREFIXES = {'outbound_selection', 'op_borrow_apply', 'inventory_stocktake', 'scrap_apply'}
def stripper(d, stock_category):
if permission_prefix is None or permission_prefix in SELECTION_PREFIXES:
if stock_category == 'buy':
for k in ('unit_price', 'post_tax_unit_price', 'total_price',
'tax_rate', 'currency', 'exchange_rate',
'pre_tax_unit_price', 'qty_inbound', 'in_quantity'):
d.pop(k, None)
elif stock_category == 'semi':
for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost',
'total_price', 'unit_price'):
d.pop(k, None)
elif stock_category == 'product':
for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost',
'sale_price', 'unit_price'):
d.pop(k, None)
return stripper
def _do_get_stock_list(permission_prefix=None):
"""
分页获取库存列表stock_quantity > 0 — 裸逻辑,供各模块复用
Args:
permission_prefix: 可选权限前缀(如 'outbound_selection' / 'op_borrow_apply')。
传入后自动剥离对应模块无权查看的价格/成本字段。
"""
try:
page = request.args.get('page', 1, type=int)
pageSize = request.args.get('pageSize', 20, type=int)
keyword = request.args.get('keyword', '', type=str).strip()
if page < 1:
page = 1
if pageSize < 1 or pageSize > 200:
pageSize = 20
# ★ Fail-Closed: 选单/借用场景默认剥离所有价格成本字段
_strip_price_fields = _make_price_stripper(permission_prefix)
# ★ 行级公司隔离:普通用户只能看到本公司的库存(超管/跨域不受限)
company_limit = get_current_company_filter()
all_items = []
# 1. 采购件
if StockBuy:
q = StockBuy.query.filter(StockBuy.stock_quantity > 0)
if company_limit is not None:
q = q.filter(StockBuy.base.has(MaterialBase.company_name == company_limit))
if keyword:
q = q.filter(
db.or_(
StockBuy.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
StockBuy.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
StockBuy.sku.ilike(f'%{keyword}%')
)
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_buy' # ★ 报废等按单流程需要精准定位来源表
d['stock_type'] = 'material'
d['type'] = 'material'
d['typeLabel'] = '采购件'
d['name'] = d.get('material_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
_strip_price_fields(d, 'buy')
all_items.append(d)
# 2. 半成品
if StockSemi:
try:
q = StockSemi.query.filter(StockSemi.stock_quantity > 0)
if company_limit is not None:
q = q.filter(StockSemi.base.has(MaterialBase.company_name == company_limit))
if keyword:
q = q.filter(
db.or_(
StockSemi.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
StockSemi.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
StockSemi.sku.ilike(f'%{keyword}%')
)
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_semi' # ★ 精准来源表
d['stock_type'] = 'semi'
d['type'] = 'semi'
d['typeLabel'] = '半成品'
d['name'] = d.get('material_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
_strip_price_fields(d, 'semi')
all_items.append(d)
except Exception:
pass
# 3. 成品
if StockProduct:
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if company_limit is not None:
q = q.filter(StockProduct.base.has(MaterialBase.company_name == company_limit))
if keyword:
q = q.filter(
db.or_(
StockProduct.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
StockProduct.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
StockProduct.sku.ilike(f'%{keyword}%'),
StockProduct.barcode.ilike(f'%{keyword}%'),
StockProduct.serial_number.ilike(f'%{keyword}%')
)
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_product' # ★ 精准来源表
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'
d['name'] = d.get('material_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
_strip_price_fields(d, 'product')
all_items.append(d)
# ── 按规格+库位聚合(出库选单合并同类项)───────────────────────
is_aggregated = request.args.get('is_aggregated', 'false').lower() == 'true'
if is_aggregated:
grouped_dict = {}
for item in all_items:
# 核心聚合键:类型 + 规格型号 + 库位 + base_id含 base_id 防止不同物料被错误合并)
group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}_{item.get('base_id', '')}"
if group_key in grouped_dict:
# 累加数量
existing = grouped_dict[group_key]
existing['available_quantity'] = float(existing.get('available_quantity', 0)) + float(item.get('available_quantity', 0))
existing['stock_quantity'] = float(existing.get('stock_quantity', 0)) + float(item.get('stock_quantity', 0))
# 保留 id 列表(出库提交时需用到)
existing_ids = existing.get('_ids', [])
existing_ids.append(item.get('id'))
existing['_ids'] = existing_ids
else:
# 存入代表项
grouped_dict[group_key] = item.copy()
# 强制统一数据类型以便前端处理
grouped_dict[group_key]['available_quantity'] = float(item.get('available_quantity', 0))
grouped_dict[group_key]['stock_quantity'] = float(item.get('stock_quantity', 0))
grouped_dict[group_key]['_ids'] = [item.get('id')]
# 替换原列表为聚合后的列表
all_items = list(grouped_dict.values())
# ── 手动切片分页 ────────────────────────────────────────────
total = len(all_items)
start = (page - 1) * pageSize
end = start + pageSize
paged = all_items[start:end]
return jsonify({
'msg': '获取成功',
'data': {
'list': paged,
'total': total,
'page': page,
'pageSize': pageSize
}
}), 200
except Exception as e:
current_app.logger.error(f"Get Stock List Failed: {str(e)}")
return jsonify({'msg': f'获取库存列表失败: {str(e)}'}), 500
@bp.route('/list', methods=['GET'])
@jwt_required()
@permission_required('outbound_selection')
def get_stock_list():
"""出库选单专用库存列表 — Fail-Closed: 剥离价格字段"""
return _do_get_stock_list(permission_prefix='outbound_selection')
# --------------------------------------------------------
# 盘库/出库/借库 扫码精确匹配接口
# GET /api/v1/inbound/stock/scan?barcode=xxx
# 精确匹配优先,替代前端 pageSize:10 模糊搜索 + find() 的漏匹配问题
# --------------------------------------------------------
@bp.route('/scan', methods=['GET'])
@jwt_required()
def scan_stock_by_barcode():
"""根据条码精确匹配库存记录(一次返回唯一命中,性能好且准确)"""
try:
barcode = request.args.get('barcode', '').strip()
if not barcode:
return jsonify({'code': 400, 'msg': 'barcode 不能为空'}), 400
item, source_table, stock_id = get_stock_info(barcode, get_current_company_filter())
if not item:
return jsonify({'code': 404, 'msg': f'未找到该物料库存: {barcode}'}), 404
d = item.to_dict()
d['stock_type'] = source_table.replace('stock_', '')
d['type'] = source_table.replace('stock_', '')
d['source_table'] = source_table
d['stock_id'] = stock_id
# 兼容前端字段
if hasattr(item, 'base') and item.base:
d['name'] = d.get('material_name') or item.base.name or ''
d['standard'] = d.get('spec_model') or item.base.spec_model or ''
d['stock_quantity'] = float(d.get('stock_quantity') or d.get('qty_stock') or 0)
d['available_quantity'] = float(d.get('available_quantity') or d.get('qty_available') or 0)
return jsonify({'code': 200, 'msg': 'success', 'data': d}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# --- 草稿箱接口 ---
@bp.route('/draft/list', methods=['GET'])
@permission_required('inventory_stocktake')
def get_drafts():
"""
获取盘点草稿列表
支持分页、搜索SKU和排序
"""
# 获取分页参数
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 20, type=int)
keyword = request.args.get('keyword', '', type=str)
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)
# ── 公共 JOIN 片段:草稿按 source_table 关联三张库存表 + material_base ──
join_sql = """
FROM stocktake_draft sd
LEFT JOIN stock_buy b ON sd.source_table = 'stock_buy' AND b.id = sd.stock_id
LEFT JOIN stock_semi s ON sd.source_table = 'stock_semi' AND s.id = sd.stock_id
LEFT JOIN stock_product p ON sd.source_table = 'stock_product' AND p.id = sd.stock_id
LEFT JOIN material_base mb ON mb.id = COALESCE(b.base_id, s.base_id, p.base_id)
"""
# ── 动态 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
if uuid:
conditions.append('sd.uuid = :uuid')
params['uuid'] = uuid
if keyword:
conditions.append("LOWER(COALESCE(b.sku, s.sku, p.sku, '')) LIKE :kw")
params['kw'] = f'%{keyword.lower()}%'
where_clause = ('WHERE ' + ' AND '.join(conditions)) if conditions else ''
# ── 总数COUNT 聚合,不再全量拉取到内存)──
count_sql = f'SELECT COUNT(*) {join_sql} {where_clause}'
total = db.session.execute(db.text(count_sql), params).scalar() or 0
# ── 真实已盘数(按库存维度去重)──
scanned_sql = f"""
SELECT COUNT(*) FROM (
SELECT 1 {join_sql} {where_clause}
GROUP BY sd.source_table, sd.stock_id
) t
"""
total_scanned = db.session.execute(db.text(scanned_sql), params).scalar() or 0
# ── 数据查询(真正的数据库级 LIMIT/OFFSET 分页,一次性连表取字段)──
offset = (page - 1) * limit
data_sql = f"""
SELECT
sd.id AS draft_id, sd.user_id, sd.uuid, sd.quantity, sd.scan_time,
sd.session_id, sd.source_table, sd.stock_id, sd.stock_qty, sd.diff_qty, sd.remark,
COALESCE(b.sku, s.sku, p.sku, '') AS sku,
mb.name AS material_name,
mb.spec_model AS spec_model
{join_sql}
{where_clause}
ORDER BY LOWER(COALESCE(b.sku, s.sku, p.sku, ''))
LIMIT :limit OFFSET :offset
"""
query_params = dict(params)
query_params['limit'] = limit
query_params['offset'] = offset
rows = db.session.execute(db.text(data_sql), query_params).fetchall()
# ── 组装返回结构(仅当前页数据,无 N+1──
items = []
for row in rows:
items.append({
'id': row.draft_id,
'user_id': row.user_id,
'uuid': row.uuid,
'quantity': float(row.quantity or 1),
'scan_time': row.scan_time.strftime('%Y-%m-%d %H:%M:%S') if row.scan_time else None,
'session_id': row.session_id,
'source_table': row.source_table,
'stock_id': row.stock_id,
'stock_qty': float(row.stock_qty or 0),
'diff_qty': float(row.diff_qty or 0),
'remark': row.remark,
'sku': row.sku or '',
'material_name': row.material_name or '',
'spec_model': row.spec_model or ''
})
return jsonify({
'items': items,
'total': total,
'total_scanned': total_scanned,
'page': page,
'limit': limit
}), 200
@bp.route('/draft/add', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def add_draft():
"""
扫码同步 (支持更新数量)
如果 session_id 不存在则创建新的会话
差异计算逻辑调整:
- adjusted_stock_qty = 账面总库存 - 借出未还数量
- diff_qty = 实盘数量 - adjusted_stock_qty
"""
try:
data = request.json
user_id = _normalize_user_id()
uuid = data.get('uuid')
print(f"🚀 [SCAN DEBUG] 后端实际接收到的 UUID 原文: |{uuid}| (长度: {len(str(uuid)) if uuid else 0})")
quantity = float(data.get('quantity', 1))
session_id = data.get('session_id')
# ★ 新增: 提取备注字段
remark = data.get('remark')
if not uuid:
return jsonify({"message": "UUID不能为空"}), 400
# 如果没有 session_id创建新的
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, company_name)
if not item:
return jsonify({"message": "未找到对应的库存记录"}), 404
# 账面总库存
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
# 计算借出未还数量 (quantity - returned_quantity)
borrowed_result = db.session.query(
db.func.sum(db.func.coalesce(TransBorrow.quantity, 0) - db.func.coalesce(TransBorrow.returned_quantity, 0))
).filter(
TransBorrow.source_table == source_table,
TransBorrow.stock_id == stock_id,
TransBorrow.is_returned == False
).scalar()
total_borrowed = float(borrowed_result) if borrowed_result else 0
# 调整后的账面可用库存 = 账面总库存 - 借出未还数量
adjusted_stock_qty = stock_qty - total_borrowed
# ★ 查找是否已存在:按 (company_name, session_id, uuid) 去重,不按 user_id 隔离
# 修复: 同一盘点单多个用户(手机/平板不同账号)操作同一物料时,
# 之前按 user_id 匹配导致每个用户各建一条 → 重复记录
# ★ 公司隔离: 去重键必须含公司,否则跨公司同码物料会互相覆盖
draft = StocktakeDraft.query.filter_by(
uuid=uuid, session_id=session_id, company_name=company_name
).first()
if draft:
# 如果已存在,更新数量和时间
draft.quantity = quantity
draft.scan_time = datetime.now()
draft.stock_qty = adjusted_stock_qty
draft.diff_qty = quantity - adjusted_stock_qty
draft.source_table = source_table
draft.stock_id = stock_id
# 更新操作用户(最后操作者)
draft.user_id = user_id
# ★ 新增: 保存备注
if remark is not None:
draft.remark = remark.strip() if isinstance(remark, str) else remark
else:
# 如果不存在,创建新的
draft = StocktakeDraft(
user_id=user_id,
uuid=uuid,
quantity=quantity,
session_id=session_id,
stock_qty=adjusted_stock_qty,
diff_qty=quantity - adjusted_stock_qty,
source_table=source_table,
stock_id=stock_id,
# ★ 新增: 保存备注
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.commit()
return jsonify({
"message": "Saved",
"session_id": session_id,
"draft_id": draft.id,
"adjusted_stock_qty": adjusted_stock_qty,
"total_borrowed": total_borrowed
}), 200
except Exception as e:
print(f"Add Draft Error: {e}")
db.session.rollback()
return jsonify({"message": str(e)}), 500
@bp.route('/draft/clear', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def clear_draft():
"""
清除盘点草稿
★ 必须指定 session_id —— 禁止不传参全表清空(历史上会误删其他公司/用户的进度)。
清除范围同时受公司隔离约束。
"""
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.filter_by(session_id=session_id)
if company_name is not None:
query = query.filter_by(company_name=company_name)
# 改为对象级删除以触发审计事件
records = query.all()
count = len(records)
for rec in records:
db.session.delete(rec)
db.session.commit()
return jsonify({"message": f"已清除 {count} 条记录", "count": count}), 200
except Exception as e:
db.session.rollback()
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不删除任何历史数据。
★ 改造:原实现执行 StocktakeDraft.query.all() 并逐条 delete
「开启新盘点」会清空**整张表**,抹掉其他公司、其他用户正在进行的盘点进度。
现改为纯签发:新旧会话靠 session_id 天然隔离,历史草稿原样保留,
需要清理时走 /draft/clear必须显式指定 session_id
"""
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
# --- 盘点结束与差异报告 ---
@bp.route('/finish', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def finish_stocktake():
"""
结束盘点
直接返回成功,前端会跳转到差异列表
草稿数据保留在表中
"""
return jsonify({
"message": "盘点已结束",
"code": 200
}), 200
@bp.route('/variance-report', methods=['GET'])
@permission_required('inventory_stocktake')
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)
# 只返回有差异的记录
drafts = query.filter(StocktakeDraft.diff_qty != 0).order_by(
StocktakeDraft.scan_time.desc(),
StocktakeDraft.diff_qty.desc()
).all()
# 补充库存详情
result = []
for draft in drafts:
draft_dict = draft.to_dict()
# 获取库存详情
if draft.stock_id and draft.source_table:
stock = get_stock_record(draft.source_table, draft.stock_id)
if stock:
draft_dict['stock_name'] = getattr(stock, 'material_name', None) or \
getattr(stock, 'product_name', None) or ''
draft_dict['stock_spec'] = getattr(stock, 'spec_model', '') or \
getattr(stock, 'standard', '') or ''
draft_dict['stock_location'] = getattr(stock, 'warehouse_location', '') or ''
draft_dict['stock_unit'] = getattr(stock, 'unit', '')
result.append(draft_dict)
return jsonify({
"list": result,
"total": len(result)
}), 200
except Exception as e:
print(f"Error: {e}")
return jsonify({"message": str(e)}), 500
# --- 单条库存调整 (手动平账) ---
@bp.route('/adjust', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def adjust_stock():
"""
单条库存调整接口
接收指定的草稿 ID执行以下操作
1. 根据 diff_qty 调整库存 (stock_quantity 和 available_quantity)
2. 生成流水账记录 (盘盈入库 / 盘亏出库)
3. 标记草稿为 is_processed=True
支持两种模式:
- 有草稿模式:通过 draft_id 或 stock_id+source_table 查找草稿
- 无草稿模式:直接传入 stock_id + diff_qty + source_table未扫码直接盘亏
"""
data = request.json
draft_id = data.get('draft_id')
stock_id = data.get('stock_id')
diff_qty = data.get('diff_qty')
source_table = data.get('source_table')
operator_name = data.get('operator_name', 'System')
remark = data.get('remark', '')
if not draft_id and not stock_id:
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_query = StocktakeDraft.query.filter_by(
stock_id=stock_id,
source_table=source_table
)
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_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:
# 有草稿模式
stock_id = draft.stock_id
source_table = draft.source_table
diff_qty = float(draft.diff_qty)
else:
# 无草稿模式(未扫码直接盘亏)
if diff_qty is None or source_table is None or not stock_id:
return jsonify({"message": "未扫码物资平账缺失必要参数(需提供 diff_qty 和 source_table"}), 400
diff_qty = float(diff_qty)
# 3. 获取并校验真实的库存记录 — ★ 修复并发:使用悲观锁防止 TOCTOU
stock = get_stock_record(source_table, stock_id, for_update=True)
if not stock:
return jsonify({"message": "平账失败:物理库存记录已不存在"}), 404
# 4. 计算调整
if diff_qty > 0:
# 盘盈:增加库存
new_stock_qty = float(stock.stock_quantity or 0) + diff_qty
new_avail_qty = float(stock.available_quantity or 0) + diff_qty
action_type = '盘盈入库'
elif diff_qty < 0:
# 盘亏:减少库存
abs_diff = abs(diff_qty)
current_avail = float(stock.available_quantity or 0)
if current_avail < abs_diff:
return jsonify({
"message": f"可用库存不足,当前可用: {current_avail},需要减少: {abs_diff}"
}), 400
new_stock_qty = float(stock.stock_quantity or 0) - abs_diff
new_avail_qty = current_avail - abs_diff
action_type = '盘亏出库'
else:
return jsonify({"message": "差异为0无需调整"}), 400
# 5. 执行库存调整
stock.stock_quantity = new_stock_qty
stock.available_quantity = new_avail_qty
# 6. 生成流水账记录
from app.models.outbound import TransOutbound
# 生成唯一单号
adj_no_suffix = draft.id if draft else f"MANUAL-{datetime.now().strftime('%Y%m%d%H%M%S')}"
trans_record = TransOutbound(
outbound_no=f"STKADJ-{datetime.now().strftime('%Y%m%d%H%M%S')}-{adj_no_suffix}",
sku=stock.sku,
source_table=source_table,
stock_id=stock_id,
barcode=getattr(stock, 'barcode', ''),
quantity=abs(diff_qty),
unit_price=getattr(stock, 'pre_tax_unit_price', 0) or getattr(stock, 'manual_cost', 0) or 0,
outbound_type=action_type, # 盘盈入库 / 盘亏出库
consumer_name='盘点调整',
operator_name=operator_name,
remark=f"{action_type} - 盘点差异调整,备注: {remark}",
outbound_time=datetime.now()
)
db.session.add(trans_record)
# 7. 删除草稿记录(仅在有草稿时)
if draft:
db.session.delete(draft)
db.session.commit()
return jsonify({
"message": f"{action_type}成功",
"action_type": action_type,
"diff_qty": diff_qty,
"new_stock_qty": new_stock_qty,
"new_avail_qty": new_avail_qty,
"draft_id": draft_id if draft_id else (draft.id if draft else None)
}), 200
except Exception as e:
db.session.rollback()
print(f"Adjust Stock Error: {e}")
return jsonify({"message": str(e)}), 500
@bp.route('/borrowed-quantities', methods=['POST'])
@permission_required('inventory_stocktake')
def get_borrowed_quantities():
"""批量获取借出未还数量"""
data = request.json.get('items', [])
result = {}
for item in data:
source = item.get('source_table')
stock_id = item.get('stock_id')
if source and stock_id is not None:
qty = TransBorrow.get_borrowed_quantity(source, stock_id)
result[f"{source}_{stock_id}"] = qty
return jsonify(result), 200
# --- 打印接口 ---
@bp.route('/print/selection', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def print_selection():
try:
data = request.json
items = data.get('items', [])
if not items: return jsonify({"message": "未选择任何物品"}), 400
printer = NetworkPrintService()
success, msg = printer.print_outbound_selection(items)
return jsonify({"message": "打印指令已发送" if success else msg}), 200 if success else 500
except Exception as e:
return jsonify({"message": str(e)}), 500
@bp.route('/print/stocktake', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def print_stocktake():
try:
data = request.json
printer = NetworkPrintService()
success, msg = printer.print_stocktake_report(data)
return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500
except Exception as e:
return jsonify({"message": str(e)}), 500
@bp.route('/export-stocktake', methods=['GET'])
@permission_required('inventory_stocktake:operation')
def export_stocktake():
"""
导出盘点报告 Excel
包含3个Sheet:
1. 盘点差异明细 (diff_qty != 0)
2. 账实相符明细 (diff_qty == 0)
3. 外借在用资产明细 (未归还的借出记录)
4. 未盘点明细(疑似漏盘)
"""
try:
# ★ 获取 session_id 参数,用于过滤当前会话的扫描记录
session_id = request.args.get('session_id', '', type=str)
# ★ 公司隔离:导出内容限定在当前用户所属公司
company_name = get_current_company_filter()
# 创建工作簿
wb = Workbook()
wb.remove(wb.active)
# 定义样式
header_font = Font(bold=True, size=11, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
def get_material_info(source_table, stock_id):
"""获取物料基本信息"""
if source_table == 'stock_buy':
stock = StockBuy.query.get(stock_id)
elif source_table == 'stock_semi':
stock = StockSemi.query.get(stock_id) if StockSemi else None
elif source_table == 'stock_product':
stock = StockProduct.query.get(stock_id) if StockProduct else None
else:
return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-', 'location': '-'}
if not stock:
return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-', 'location': '-'}
# 安全获取 sku
stock_sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', None) or '-'
# 使用 base_id 关联查询物料基础表
material = None
base_id = getattr(stock, 'base_id', None)
if base_id:
material = MaterialBase.query.get(base_id)
# 规格型号:从 MaterialBase 的 spec_model 字段获取
spec = getattr(material, 'spec_model', None) if material else '-'
if not spec or spec == '-':
spec = getattr(stock, 'spec_model', None) or getattr(stock, 'standard', None) or '-'
# 库位:从库存表的 warehouse_location 字段获取
location = getattr(stock, 'warehouse_location', None) or '-'
return {
'name': material.name if material else stock_sku,
'sku': stock_sku,
'spec': spec,
'unit': getattr(stock, 'unit', None) or '',
'location': location
}
def get_user_name(user_id):
"""获取用户真实姓名
SysUser.username 存储格式为 "真实姓名/登录账号" (例如: 张三/zhangsan01)
"""
if not SysUser or not user_id:
return str(user_id) if user_id else '-'
try:
user = None
# 尝试通过ID或用户名查找
if str(user_id).isdigit():
user = SysUser.query.get(int(user_id))
if not user:
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
# 注意:此处不再 fallback filter_by(username=...)
# 避免 PostgreSQL 将 user_id 数字与 username 字符串列做类型比较导致报错
if not user:
return str(user_id)
# 解析 username 格式: "张三/zhangsan01" -> 取前面的真实姓名
raw_username = getattr(user, 'username', None) or str(user_id)
if '/' in raw_username:
return raw_username.split('/')[0]
return raw_username
except:
return str(user_id)
def to_beijing_time(dt):
"""直接使用数据库中存储的标准时间(服务器时区已正确)"""
if not dt:
return ''
try:
if isinstance(dt, str):
return dt[:19]
return dt.strftime('%Y-%m-%d %H:%M:%S')
except:
return str(dt)[:19]
def set_header_row(ws, headers):
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
# ===== Sheet 1: 盘点全景汇总表 (放在最前面) =====
ws1 = wb.create_sheet("盘点全景汇总表", 0)
summary_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点状态", "盘点人", "盘点时间", "备注"]
set_header_row(ws1, summary_headers)
master_row_idx = 2 # 汇总表行计数器
# ===== Sheet 2: 盘点差异明细 =====
ws2 = wb.create_sheet("盘点差异明细")
diff_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间", "备注"]
set_header_row(ws2, diff_headers)
# 按 SKU 排序:先获取全部数据,再在 Python 中按 SKU 排序
# ★ 原依赖 /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)
diff_drafts_with_sku.append((mat_info.get('sku', ''), draft))
diff_drafts_with_sku.sort(key=lambda x: x[0] if x[0] else '')
diff_drafts = [d[1] for d in diff_drafts_with_sku]
for row_idx, draft in enumerate(diff_drafts, 2):
mat_info = get_material_info(draft.source_table, draft.stock_id)
# 写入 Sheet 2 (差异明细)
ws2.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border
ws2.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
ws2.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
ws2.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
ws2.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws2.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws2.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws2.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
ws2.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
ws2.cell(row=row_idx, column=10, value=draft.remark or '').border = thin_border
# 同时写入 Sheet 1 (汇总表)
ws1.cell(row=master_row_idx, column=1, value=mat_info['name']).border = thin_border
ws1.cell(row=master_row_idx, column=2, value=mat_info['sku']).border = thin_border
ws1.cell(row=master_row_idx, column=3, value=mat_info['spec']).border = thin_border
ws1.cell(row=master_row_idx, column=4, value=mat_info['location']).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=8, value="有差异").border = thin_border
ws1.cell(row=master_row_idx, column=9, value=get_user_name(draft.user_id)).border = thin_border
ws1.cell(row=master_row_idx, column=10, value=to_beijing_time(draft.scan_time)).border = thin_border
ws1.cell(row=master_row_idx, column=11, value=draft.remark or '').border = thin_border
master_row_idx += 1
# ===== Sheet 3: 账实相符明细 =====
ws3 = wb.create_sheet("账实相符明细")
normal_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间", "备注"]
set_header_row(ws3, normal_headers)
# 按 SKU 排序
# ★ 同上:显式按 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)
normal_drafts_with_sku.append((mat_info.get('sku', ''), draft))
normal_drafts_with_sku.sort(key=lambda x: x[0] if x[0] else '')
normal_drafts = [d[1] for d in normal_drafts_with_sku]
for row_idx, draft in enumerate(normal_drafts, 2):
mat_info = get_material_info(draft.source_table, draft.stock_id)
# 写入 Sheet 3 (账实相符)
ws3.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border
ws3.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
ws3.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
ws3.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
ws3.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws3.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws3.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws3.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
ws3.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
ws3.cell(row=row_idx, column=10, value=draft.remark or '').border = thin_border
# 同时写入 Sheet 1 (汇总表)
ws1.cell(row=master_row_idx, column=1, value=mat_info['name']).border = thin_border
ws1.cell(row=master_row_idx, column=2, value=mat_info['sku']).border = thin_border
ws1.cell(row=master_row_idx, column=3, value=mat_info['spec']).border = thin_border
ws1.cell(row=master_row_idx, column=4, value=mat_info['location']).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=8, value="正常").border = thin_border
ws1.cell(row=master_row_idx, column=9, value=get_user_name(draft.user_id)).border = thin_border
ws1.cell(row=master_row_idx, column=10, value=to_beijing_time(draft.scan_time)).border = thin_border
ws1.cell(row=master_row_idx, column=11, value=draft.remark or '').border = thin_border
master_row_idx += 1
# ===== Sheet 4: 外借在用资产明细 =====
ws4 = wb.create_sheet("外借在用资产明细")
borrow_headers = ["借出单号", "借用人", "物料名称", "SKU", "规格型号", "借出总数", "已还数量", "待还数量", "借出时间", "预计归还时间"]
set_header_row(ws4, borrow_headers)
# 查询未归还的借出记录
unreturned_borrows = TransBorrow.query.filter(TransBorrow.is_returned == False).all()
for row_idx, borrow in enumerate(unreturned_borrows, 2):
mat_info = get_material_info(borrow.source_table, borrow.stock_id)
total_qty = float(borrow.quantity or 0)
returned_qty = float(borrow.returned_quantity or 0)
pending_qty = total_qty - returned_qty
ws4.cell(row=row_idx, column=1, value=borrow.borrow_no or '').border = thin_border
ws4.cell(row=row_idx, column=2, value=borrow.borrower_name or '').border = thin_border
ws4.cell(row=row_idx, column=3, value=mat_info['name']).border = thin_border
ws4.cell(row=row_idx, column=4, value=mat_info['sku']).border = thin_border
ws4.cell(row=row_idx, column=5, value=mat_info['spec']).border = thin_border
ws4.cell(row=row_idx, column=6, value=total_qty).border = thin_border
ws4.cell(row=row_idx, column=7, value=returned_qty).border = thin_border
ws4.cell(row=row_idx, column=8, value=pending_qty).border = thin_border
ws4.cell(row=row_idx, column=9, value=to_beijing_time(borrow.borrow_time)).border = thin_border
ws4.cell(row=row_idx, column=10, value='无限期' if not borrow.expected_return_time else to_beijing_time(borrow.expected_return_time)).border = thin_border
# ===== Sheet 5: 未盘点明细(疑似漏盘) =====
# 逻辑:获取已盘点的集合,遍历库存表,找出未盘点且有库存的物资
ws5 = wb.create_sheet("未盘点明细(疑似漏盘)")
unscanned_headers = ["物料名称", "SKU", "规格型号", "库位", "批号", "调整后账面数", "实盘数", "差异数", "状态"]
set_header_row(ws5, unscanned_headers)
# 获取已盘点的 (source_table, stock_id) 集合
# ★ 修复:只查询当前 session_id 的扫描记录,避免历史记录干扰
scanned_query = StocktakeDraft.query
if session_id:
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}
# ★ 性能优化:批量预取所有未还借用的聚合数量
# 单条 GROUP BY 查询替代循环内逐条 get_borrowed_qty() N+1
borrow_rows = db.session.query(
TransBorrow.source_table,
TransBorrow.stock_id,
db.func.sum(db.func.coalesce(TransBorrow.quantity, 0) - db.func.coalesce(TransBorrow.returned_quantity, 0)).label('pending')
).filter(
TransBorrow.is_returned == False
).group_by(
TransBorrow.source_table, TransBorrow.stock_id
).all()
borrow_map = {(r.source_table, r.stock_id): float(r.pending or 0) for r in borrow_rows}
unscanned_items = []
# ★ 修复 N+1 查询:使用 joinedload 预加载 base 关系,同时过滤 stock_quantity > 0
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
# ★ 扣除外借数量O(1) 字典查找替代逐条 TransBorrow 查询
borrowed_qty = borrow_map.get(key, 0)
stock_qty = float(stock.stock_quantity or 0)
expected_qty = stock_qty - borrowed_qty
if expected_qty > 0:
# ★ 直接使用预加载的 base 关系,避免额外查询
material = stock.base
# ★ 安全提取批号/序列号:使用 getattr 降级链
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
mat_info = {
'name': material.name if material else '-',
'sku': getattr(stock, 'sku', None) or '-',
'spec': getattr(material, 'spec_model', None) if material else '-',
'location': getattr(stock, 'warehouse_location', None) or '-',
'batch_no': batch_sn
}
unscanned_items.append({
'name': mat_info['name'],
'sku': mat_info['sku'],
'spec': mat_info['spec'],
'location': mat_info['location'],
'batch_no': mat_info['batch_no'],
'stock_qty': expected_qty,
'actual_qty': 0,
'diff_qty': -expected_qty,
'status': '未盘点'
})
# 遍历 StockSemi
if StockSemi:
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
borrowed_qty = borrow_map.get(key, 0)
stock_qty = float(stock.stock_quantity or 0)
expected_qty = stock_qty - borrowed_qty
if expected_qty > 0:
# ★ 直接使用预加载的 base 关系,避免额外查询
material = stock.base
# ★ 安全提取批号/序列号:使用 getattr 降级链
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
mat_info = {
'name': material.name if material else '-',
'sku': getattr(stock, 'sku', None) or '-',
'spec': getattr(material, 'spec_model', None) if material else '-',
'location': getattr(stock, 'warehouse_location', None) or '-',
'batch_no': batch_sn
}
unscanned_items.append({
'name': mat_info['name'],
'sku': mat_info['sku'],
'spec': mat_info['spec'],
'location': mat_info['location'],
'batch_no': mat_info['batch_no'],
'stock_qty': expected_qty,
'actual_qty': 0,
'diff_qty': -expected_qty,
'status': '未盘点'
})
# 遍历 StockProduct
if StockProduct:
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
stock_qty = float(stock.stock_quantity or 0)
if stock_qty <= 0:
continue
borrowed_qty = borrow_map.get(key, 0)
expected_qty = stock_qty - borrowed_qty
if expected_qty > 0:
# ★ 直接使用预加载的 base 关系,避免额外查询
material = stock.base
# ★ 安全提取批号/序列号:使用 getattr 降级链 (成品可能无此字段)
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
mat_info = {
'name': material.name if material else '-',
'sku': getattr(stock, 'sku', None) or '-',
'spec': getattr(material, 'spec_model', None) if material else '-',
'location': getattr(stock, 'warehouse_location', None) or '-',
'batch_no': batch_sn
}
unscanned_items.append({
'name': mat_info['name'],
'sku': mat_info['sku'],
'spec': mat_info['spec'],
'location': mat_info['location'],
'batch_no': mat_info['batch_no'],
'stock_qty': expected_qty,
'actual_qty': 0,
'diff_qty': -expected_qty,
'status': '未盘点'
})
# 写入未盘点明细
for row_idx, item in enumerate(unscanned_items, 2):
# 写入 Sheet 5 (未盘点明细)
ws5.cell(row=row_idx, column=1, value=item['name']).border = thin_border
ws5.cell(row=row_idx, column=2, value=item['sku']).border = thin_border
ws5.cell(row=row_idx, column=3, value=item['spec']).border = thin_border
ws5.cell(row=row_idx, column=4, value=item['location']).border = thin_border
ws5.cell(row=row_idx, column=5, value=item.get('batch_no', '-')).border = thin_border # ★ 批号
ws5.cell(row=row_idx, column=6, value=float(item['stock_qty'])).border = thin_border
ws5.cell(row=row_idx, column=7, value=float(item['actual_qty'])).border = thin_border
ws5.cell(row=row_idx, column=8, value=float(item['diff_qty'])).border = thin_border
ws5.cell(row=row_idx, column=9, value=item['status']).border = thin_border
# 同时写入 Sheet 1 (汇总表) - 盘点人和时间留空
ws1.cell(row=master_row_idx, column=1, value=item['name']).border = thin_border
ws1.cell(row=master_row_idx, column=2, value=item['sku']).border = thin_border
ws1.cell(row=master_row_idx, column=3, value=item['spec']).border = thin_border
ws1.cell(row=master_row_idx, column=4, value=item['location']).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=float(item['stock_qty'])).border = thin_border
ws1.cell(row=master_row_idx, column=6, value=float(item['actual_qty'])).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=float(item['diff_qty'])).border = thin_border
ws1.cell(row=master_row_idx, column=8, value="未盘点").border = thin_border
ws1.cell(row=master_row_idx, column=9, value="").border = thin_border
ws1.cell(row=master_row_idx, column=10, value="").border = thin_border
master_row_idx += 1
# 调整列宽
for ws in [ws1, ws2, ws3, ws4, ws5]:
for col in ws.columns:
max_length = 0
col_letter = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
ws.column_dimensions[col_letter].width = min(max_length + 2, 30)
# 生成文件
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f"盘点报告_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
print(f"Export Stocktake Error: {e}")
import traceback
traceback.print_exc()
return jsonify({"message": f"导出失败: {str(e)}"}), 500
# --------------------------------------------------------
# 生成漏盘数据 - 将未扫描的库存标记为全额盘亏
# POST /api/v1/inbound/stocktake/generate-missing
# --------------------------------------------------------
@bp.route('/stocktake/generate-missing', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def generate_missing_stocktake():
"""
生成漏盘数据(幂等性):
找出所有真实库存 > 0但未被当前会话盘点扫描到的物料
自动生成盘点草稿,标记为盘亏(实盘=0差异=-库存数)
幂等性保护:在重新计算差集之前,先删除当前 session 下所有
由系统自动生成的漏盘记录quantity==0, user_id=='system'
保证该接口多次调用结果一致。
"""
try:
# ★ 获取 session_id 参数,用于隔离当前会话
data = request.get_json() or {}
session_id = data.get('session_id', '')
if not session_id:
return jsonify({'code': 400, 'msg': '缺少 session_id 参数'}), 400
# ★ 公司隔离:漏盘比对只在当前公司的库存与草稿范围内进行
company_name = get_current_company_filter()
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
# 特征user_id == 'system' (表示由系统自动生成)
# 改为对象级删除以触发审计事件
system_query = StocktakeDraft.query.filter(
StocktakeDraft.session_id == session_id,
StocktakeDraft.user_id == 'system'
)
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)
if deleted_count > 0:
db.session.commit()
print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录")
# 1. 获取当前会话已有盘点记录的 (source_table, stock_id) 集合
existing_query = db.session.query(
StocktakeDraft.source_table,
StocktakeDraft.stock_id
).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:
if stock_id:
scanned_keys.add((src_table, stock_id))
# 2. 获取所有真实库存 > 0 的记录
all_stock = []
# 采购库存
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,
'base_id': item.base_id,
'stock_qty': float(item.stock_quantity or 0)
})
# 半成品库存
if StockSemi:
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,
'base_id': item.base_id,
'stock_qty': float(item.stock_quantity or 0)
})
# 成品库存
if StockProduct:
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,
'base_id': item.base_id,
'stock_qty': float(item.stock_quantity or 0)
})
# 3. 找出漏盘记录(库存中有但盘点中没有的)
missing_count = 0
for stock in all_stock:
key = (stock['source_table'], stock['stock_id'])
if key not in scanned_keys:
# 生成漏盘草稿
draft = StocktakeDraft(
user_id='system', # ★ 标记为系统自动生成,用于幂等性清理
uuid=f'MISSING-{stock["source_table"]}-{stock["stock_id"]}',
quantity=0, # 实盘数为0
scan_time=beijing_time(),
session_id=session_id, # ★ 使用传入的 session_id
source_table=stock['source_table'],
stock_id=stock['stock_id'],
stock_qty=stock['stock_qty'],
diff_qty=-stock['stock_qty'], # 差异 = 0 - 库存数 = 负数
remark='未盘点到,系统自动标记为盘亏',
company_name=company_name # ★ 公司隔离
)
db.session.add(draft)
missing_count += 1
db.session.commit()
return jsonify({
'code': 200,
'msg': f'成功生成 {missing_count} 条漏盘记录',
'data': {'count': missing_count}
})
except Exception as e:
db.session.rollback()
import traceback
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'生成漏盘数据失败: {str(e)}'}), 500
# --------------------------------------------------------
# 盘点物资合并列表(服务端 JOIN draft + stock
# GET /api/v1/inbound/stock/draft/merged-list
# --------------------------------------------------------
@bp.route('/draft/merged-list', methods=['GET'])
@permission_required('inventory_stocktake')
def get_draft_merged_list():
"""
数据库级分页合并UNION ALL + LEFT JOIN + LIMIT/OFFSET
不再加载全量库存到 Python 内存,由 PostgreSQL 完成分页。
"""
try:
session_id = request.args.get('session_id', '').strip()
if not session_id:
return jsonify({'code': 400, 'msg': 'session_id 不能为空'}), 400
keyword = request.args.get('keyword', '').strip().lower()
status_filter = request.args.get('status_filter', '').strip() # counted / uncounted
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,
stock_quantity AS stock_qty, warehouse_location, base_id
FROM stock_buy WHERE stock_quantity > 0
UNION ALL
SELECT id, 'stock_semi', sku,
stock_quantity, warehouse_location, base_id
FROM stock_semi WHERE stock_quantity > 0
UNION ALL
SELECT id, 'stock_product', sku,
stock_quantity, warehouse_location, base_id
FROM stock_product WHERE stock_quantity > 0
"""
# ── 构建 WHERE 条件 ──
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}%'
if status_filter == 'counted':
conditions.append("COALESCE(sd.quantity, 0) > 0")
elif status_filter == 'uncounted':
conditions.append("COALESCE(sd.quantity, 0) <= 0")
where_clause = ('WHERE ' + ' AND '.join(conditions)) if conditions else ''
# ── COUNT 查询 ──
count_sql = f"""
SELECT COUNT(*) FROM (
{union_sql}
) 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{company_cond_sd}
{where_clause}
"""
total = db.session.execute(db.text(count_sql), params).scalar()
# ── 已扫数量(去重) ──
scanned_sql = f"""
SELECT COUNT(DISTINCT (source_table, stock_id))
FROM stocktake_draft WHERE session_id = :sid{company_cond_bare}
"""
# 该语句只引用 :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
data_sql = f"""
SELECT
cs.id AS stock_id, cs.source_table, cs.sku,
cs.stock_qty, cs.warehouse_location,
mb.name AS mat_name, mb.spec_model AS mat_spec,
sd.id AS draft_id, sd.quantity AS draft_qty,
sd.remark AS draft_remark
FROM (
{union_sql}
) 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{company_cond_sd}
{where_clause}
ORDER BY cs.sku
LIMIT :limit OFFSET :offset
"""
params['limit'] = page_size
params['offset'] = offset
rows = db.session.execute(db.text(data_sql), params).fetchall()
# ── 组装结果 ──
items = []
for row in rows:
draft_qty = float(row.draft_qty or 0)
stock_qty = float(row.stock_qty or 0)
diff_qty = (draft_qty - stock_qty) if row.draft_id else -stock_qty
items.append({
'draft_id': row.draft_id,
'stock_id': row.stock_id,
'source_table': row.source_table,
'uniqueKey': f"{row.source_table}_{row.stock_id}",
'sku': row.sku or '',
'material_name': row.mat_name or '',
'spec_model': row.mat_spec or '',
'stock_qty': stock_qty,
'quantity': draft_qty,
'diff_qty': round(diff_qty, 4),
'remark': row.draft_remark or '',
'warehouse_location': row.warehouse_location or '',
})
return jsonify({
'code': 200, 'msg': '获取成功',
'data': {
'list': items, 'total': total,
'total_scanned': total_scanned,
'page': page, 'pageSize': page_size
}
}), 200
except Exception as e:
current_app.logger.error(f'合并列表查询失败: {str(e)}')
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 获取应盘物资清单(盘点基数)
# GET /api/v1/inbound/stock/stocktake/all-items
# --------------------------------------------------------
@bp.route('/stocktake/all-items', methods=['GET'])
@permission_required('inventory_stocktake')
def get_all_stocktake_items():
"""
获取应盘物资清单(库存 > 0 的物料)— ★ 分页返回,禁止全量
性能优化: 原来三次 .all() 全量加载 + 内存排序,库存量大时打开极慢。
改为: 分页返回 {items(当前页), total(总数), total_scanned(已盘数)}。
"""
try:
keyword = request.args.get('keyword', '', type=str).strip()
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 ''
# ── 原生 SQLUNION ALL 三张库存表 + LEFT JOIN material_base数据库级分页 ──
# 注意stock_product 表没有 batch_number 列,故第三个分支直接用 serial_number。
union_sql = """
SELECT id, 'stock_buy' AS source_table, sku, barcode,
stock_quantity AS stock_qty, available_quantity, warehouse_location, base_id,
COALESCE(batch_number, serial_number, '') AS batch_no
FROM stock_buy WHERE stock_quantity > 0
UNION ALL
SELECT id, 'stock_semi' AS source_table, sku, barcode,
stock_quantity, available_quantity, warehouse_location, base_id,
COALESCE(batch_number, serial_number, '') AS batch_no
FROM stock_semi WHERE stock_quantity > 0
UNION ALL
SELECT id, 'stock_product' AS source_table, sku, barcode,
stock_quantity, available_quantity, warehouse_location, base_id,
serial_number AS batch_no
FROM stock_product WHERE stock_quantity > 0
"""
# ── 动态 WHERESKU / 物料名 / 规格 模糊搜索)──
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()}%'
where_clause = ('WHERE ' + ' AND '.join(conditions)) if conditions else ''
# 总数COUNT 聚合,不再全量加载)
count_sql = f"""
SELECT COUNT(*) FROM ( {union_sql} ) cs
LEFT JOIN material_base mb ON mb.id = cs.base_id
{where_clause}
"""
total = db.session.execute(db.text(count_sql), params).scalar() or 0
# 数据查询(真正的数据库级 LIMIT/OFFSET 分页)
offset = (page - 1) * pageSize
data_sql = f"""
SELECT cs.id, cs.source_table, cs.sku, cs.barcode, cs.batch_no,
cs.stock_qty, cs.available_quantity, cs.warehouse_location,
mb.name AS material_name, mb.spec_model AS spec_model
FROM ( {union_sql} ) cs
LEFT JOIN material_base mb ON mb.id = cs.base_id
{where_clause}
ORDER BY LOWER(cs.sku)
LIMIT :limit OFFSET :offset
"""
query_params = dict(params)
query_params['limit'] = pageSize
query_params['offset'] = offset
rows = db.session.execute(db.text(data_sql), query_params).fetchall()
# ── 组装当前页数据(无 item.base 懒加载 N+1──
paged = []
for row in rows:
paged.append({
'id': row.id,
'sku': row.sku or '',
'barcode': row.barcode or '',
'batch_no': row.batch_no or '',
'material_name': row.material_name or '',
'spec_model': row.spec_model or '',
'stock_qty': float(row.stock_qty or 0),
'available_qty': float(row.available_quantity or 0),
'source_table': row.source_table,
'warehouse_location': row.warehouse_location or ''
})
# 统计已盘数量(该 session 下已扫的SQL COUNT 聚合)
session_id = request.args.get('session_id', '', type=str)
total_scanned = 0
if session_id:
scanned_sql = f"""
SELECT COUNT(DISTINCT (source_table, stock_id))
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), scanned_params
).scalar() or 0
return jsonify({
'code': 200,
'data': {
'items': paged,
'total': total,
'total_scanned': total_scanned,
'page': page,
'pageSize': pageSize
}
}), 200
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取应盘清单失败: {str(e)}'}), 500
# --------------------------------------------------------
# 更新盘点实盘数(手动修改)
# POST /api/v1/inbound/stock/stocktake/update-quantity
# --------------------------------------------------------
@bp.route('/stocktake/update-quantity', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def update_stocktake_quantity():
"""
更新盘点实盘数
用于手动修改盘点数量
"""
try:
data = request.json
stock_id = data.get('stock_id')
source_table = data.get('source_table')
session_id = data.get('session_id') # ★ 修复:按会话隔离,防止跨会话误改
quantity = float(data.get('quantity', 0))
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,
source_table=source_table
)
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:
return jsonify({'code': 404, 'msg': '未找到盘点记录'}), 404
# 更新数量
draft.quantity = quantity
draft.scan_time = beijing_time()
# 计算差异
draft.diff_qty = quantity - float(draft.stock_qty or 0)
db.session.commit()
return jsonify({'code': 200, 'msg': '更新成功'}), 200
except Exception as e:
db.session.rollback()
import traceback
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'更新失败: {str(e)}'}), 500