Files
KCGL/inventory-backend/app/api/v1/inbound/stock.py
yueli 6ba20fa3db fix(stocktake): 修复盘库重复记录 — 去重条件去掉 user_id
根因: draft/add 用 (user_id, uuid, session_id) 匹配去重,
同一盘点单多个用户(手机/平板不同账号)操作同一物料时,
每个用户各建一条 → 重复记录。

修复:
- 去重改为 (session_id, uuid) 唯一,不按 user_id 隔离
- 已存在则更新数量并记录最后操作用户
- 清理现有重复数据(保留每组最小id)
- 新增唯一索引 uq_stocktake_draft_session_uuid 防未来再重复

数据库需执行:
DELETE FROM stocktake_draft a USING stocktake_draft b
  WHERE a.session_id=b.session_id AND a.uuid=b.uuid AND a.id>b.id;
CREATE UNIQUE INDEX IF NOT EXISTS uq_stocktake_draft_session_uuid
  ON stocktake_draft(session_id, uuid);
2026-08-31 15:18:47 +08:00

1774 lines
75 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
from sqlalchemy.orm import joinedload
import uuid as uuid_module
import io
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 get_stock_info(uuid_or_barcode):
"""
根据 uuid 或 barcode 查询库存信息(★ 精确匹配优先,性能与准确性兼顾)
修复: 原来用 ilike %x% 全表模糊搜索 + .first()
在 SKU 前缀相同的场景会命中错误记录或漏匹配。
改为: 精确匹配(==)优先,命中即返回;无精确命中再回退模糊搜索。
返回: (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 = model.query.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 = model.query.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 场景由调用方自行处理)。
"""
# 已知的选单/借用前缀 → 无条件剥离所有价格成本
SELECTION_PREFIXES = {'outbound_selection', 'op_borrow_apply', 'inventory_stocktake'}
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)
all_items = []
# 1. 采购件
if StockBuy:
q = StockBuy.query.filter(StockBuy.stock_quantity > 0)
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['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 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['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 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['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)
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)
query = StocktakeDraft.query
if session_id:
query = query.filter_by(session_id=session_id)
# ★ 按 uuid 精确过滤(扫码时检测该物料是否已盘)
if uuid:
query = query.filter_by(uuid=uuid)
# 先执行查询获取所有记录
drafts = query.all()
items = []
for draft in drafts:
# 获取 SKU 信息
sku = ''
material_name = ''
spec_model = ''
# 根据source_table获取对应的库存记录
stock_model = get_stock_model(draft.source_table)
if stock_model and draft.stock_id:
stock = stock_model.query.get(draft.stock_id)
if stock:
sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', '')
# 如果有关键词,进行 SKU 模糊匹配
if keyword and sku:
if keyword.lower() not in sku.lower():
continue
# 获取物料基础信息
base_id = getattr(stock, 'base_id', None)
if base_id:
material = MaterialBase.query.get(base_id)
if material:
material_name = material.name
spec_model = material.spec_model
item = draft.to_dict()
item['sku'] = sku
item['material_name'] = material_name
item['spec_model'] = spec_model
items.append(item)
# 按 SKU 升序排序
items.sort(key=lambda x: (x['sku'] or '').lower())
# 手动分页
total = len(items)
start = (page - 1) * limit
end = start + limit
# 计算真实的去重"已盘数量"
counted_items_set = set()
for draft_item in items:
# 兼容判断 quantity 或 qty_actual
if draft_item.get('quantity') is not None or draft_item.get('qty_actual') is not None:
unique_key = f"{draft_item.get('source_table', '')}_{draft_item.get('stock_id', '')}"
counted_items_set.add(unique_key)
total_scanned_unique = len(counted_items_set)
paginated_items = items[start:end]
return jsonify({
'items': paginated_items,
'total': total,
'total_scanned': total_scanned_unique,
'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]}"
# 获取库存信息
item, source_table, stock_id = get_stock_info(uuid)
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
# ★ 查找是否已存在:按 (session_id, uuid) 去重,不按 user_id 隔离
# 修复: 同一盘点单多个用户(手机/平板不同账号)操作同一物料时,
# 之前按 user_id 匹配导致每个用户各建一条 → 重复记录
draft = StocktakeDraft.query.filter_by(uuid=uuid, session_id=session_id).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)
)
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
session_id = data.get('session_id')
try:
query = StocktakeDraft.query
if session_id:
# 清除指定会话
query = query.filter_by(session_id=session_id)
# 改为对象级删除以触发审计事件
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('/draft/start-new', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def start_new_session():
"""
开始新一轮盘点
清空整张草稿表,返回新的 session_id
"""
try:
# 清空整张草稿表(改为对象级删除以触发审计事件)
all_records = StocktakeDraft.query.all()
deleted_count = len(all_records)
for rec in all_records:
db.session.delete(rec)
db.session.commit()
# 生成新的 session_id
new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}"
return jsonify({
"message": f"已清除 {deleted_count} 条旧记录",
"session_id": new_session_id,
"cleared_count": deleted_count
}), 200
except Exception as e:
db.session.rollback()
return jsonify({"message": str(e)}), 500
# --- 盘点结束与差异报告 ---
@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')
try:
query = StocktakeDraft.query
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:
# 1. 尝试获取草稿
draft = StocktakeDraft.query.get(draft_id) if draft_id else None
if not draft and stock_id and source_table:
draft = StocktakeDraft.query.filter_by(
stock_id=stock_id,
source_table=source_table
).first()
elif not draft and stock_id:
draft = StocktakeDraft.query.filter_by(stock_id=stock_id).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)
# 创建工作簿
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 排序
diff_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0).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 排序
normal_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty == 0).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 的扫描记录,避免历史记录干扰
if session_id:
session_drafts = StocktakeDraft.query.filter_by(session_id=session_id).all()
else:
# 如果没有传 session_id使用所有记录兼容旧行为
session_drafts = StocktakeDraft.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 StockBuy.query.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 StockSemi.query.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 StockProduct.query.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
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
# 特征user_id == 'system' (表示由系统自动生成)
# 改为对象级删除以触发审计事件
system_records = StocktakeDraft.query.filter(
StocktakeDraft.session_id == session_id,
StocktakeDraft.user_id == 'system'
).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_records = db.session.query(
StocktakeDraft.source_table,
StocktakeDraft.stock_id
).filter(StocktakeDraft.session_id == session_id).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 StockBuy.query.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 StockSemi.query.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 StockProduct.query.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='未盘点到,系统自动标记为盘亏'
)
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)
# ── 公共 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 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
{where_clause}
"""
total = db.session.execute(db.text(count_sql), params).scalar()
# ── 已扫数量(去重) ──
scanned_sql = """
SELECT COUNT(DISTINCT (source_table, stock_id))
FROM stocktake_draft WHERE session_id = :sid
"""
total_scanned = db.session.execute(db.text(scanned_sql), {'sid': session_id}).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
{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)))
all_items = []
# 1. 采购件
buy_query = StockBuy.query.filter(StockBuy.stock_quantity > 0)
if keyword:
buy_query = buy_query.join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
db.or_(
StockBuy.sku.ilike(f'%{keyword}%'),
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
)
for item in buy_query.all():
all_items.append({
'id': item.id,
'sku': item.sku or '',
'barcode': item.barcode or '',
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '',
'stock_qty': float(item.stock_quantity or 0),
'available_qty': float(item.available_quantity or 0),
'source_table': 'stock_buy',
'warehouse_location': item.warehouse_location or ''
})
# 2. 半成品
if StockSemi:
semi_query = StockSemi.query.filter(StockSemi.stock_quantity > 0)
if keyword:
semi_query = semi_query.join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
db.or_(
StockSemi.sku.ilike(f'%{keyword}%'),
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
)
for item in semi_query.all():
all_items.append({
'id': item.id,
'sku': item.sku or '',
'barcode': item.barcode or '',
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '',
'stock_qty': float(item.stock_quantity or 0),
'available_qty': float(item.available_quantity or 0),
'source_table': 'stock_semi',
'warehouse_location': item.warehouse_location or ''
})
# 3. 成品
if StockProduct:
product_query = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if keyword:
product_query = product_query.join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
db.or_(
StockProduct.sku.ilike(f'%{keyword}%'),
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
)
for item in product_query.all():
all_items.append({
'id': item.id,
'sku': item.sku or '',
'barcode': item.barcode or '',
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '',
'stock_qty': float(item.stock_quantity or 0),
'available_qty': float(item.available_quantity or 0),
'source_table': 'stock_product',
'warehouse_location': item.warehouse_location or ''
})
# 按 SKU 排序
all_items.sort(key=lambda x: (x['sku'] or '').lower())
# ★ 分页切片
total = len(all_items)
start = (page - 1) * pageSize
paged = all_items[start:start + pageSize]
# 统计已盘数量(该 session 下已扫的)
session_id = request.args.get('session_id', '', type=str)
total_scanned = 0
if session_id:
from app.models.inbound.stocktake import StocktakeDraft
total_scanned = StocktakeDraft.query.filter(
StocktakeDraft.session_id == session_id
).count()
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')
quantity = float(data.get('quantity', 0))
if not stock_id or not source_table:
return jsonify({'code': 400, 'msg': '缺少必要参数'}), 400
# 查找对应的盘点记录
draft = StocktakeDraft.query.filter_by(
stock_id=stock_id,
source_table=source_table
).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