feat: 盘点合并列表 — 服务端JOIN+DB分页,消除99999全量加载
## stock.py - 新增 GET /draft/merged-list: UNION ALL三表+LEFT JOIN draft 数据库级 LIMIT/OFFSET 分页,不再加载全量到Python内存 - 参数: session_id, keyword, status_filter, page, pageSize - 返回: list, total, total_scanned (已扫去重数) ## stock.ts - 新增 getDraftMergedList() API函数 ## stocktake/index.vue - fetchInventoryList: 改用merged-list单次调用替代99999全量+find() - resumeSession: limit 99999→1 先检查存在,再limit 500加载
This commit is contained in:
@ -1408,6 +1408,135 @@ def generate_missing_stocktake():
|
|||||||
return jsonify({'code': 500, 'msg': f'生成漏盘数据失败: {str(e)}'}), 500
|
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
|
# GET /api/v1/inbound/stock/stocktake/all-items
|
||||||
|
|||||||
@ -73,6 +73,21 @@ export function getBom(parentId: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 盘点物资合并列表(服务端 JOIN draft + stock,替代前端全量加载+find)
|
||||||
|
export function getDraftMergedList(params: {
|
||||||
|
session_id: string
|
||||||
|
keyword?: string
|
||||||
|
status_filter?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/inbound/stock/draft/merged-list',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取应盘物资清单(盘点基数)
|
// 获取应盘物资清单(盘点基数)
|
||||||
export function getAllStocktakeItems(params?: { keyword?: string; session_id?: string }) {
|
export function getAllStocktakeItems(params?: { keyword?: string; session_id?: string }) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@ -413,7 +413,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { getStockList, getAllStocktakeItems, updateStocktakeQuantity } from '@/api/inbound/stock'
|
import { getStockList, getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity } from '@/api/inbound/stock'
|
||||||
import QrScanner from '@/components/QrScanner/index.vue'
|
import QrScanner from '@/components/QrScanner/index.vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
|
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
|
||||||
@ -654,19 +654,28 @@ const resumeSession = async () => {
|
|||||||
try {
|
try {
|
||||||
// 获取最新的会话(后端已移除 is_finished 字段)
|
// 获取最新的会话(后端已移除 is_finished 字段)
|
||||||
// 后端返回格式已改为 { items: [], total: number }
|
// 后端返回格式已改为 { items: [], total: number }
|
||||||
|
// ★ 仅检查是否有草稿存在(取1条即可,无需99999全量)
|
||||||
const res: any = await request({
|
const res: any = await request({
|
||||||
url: '/v1/inbound/stock/draft/list',
|
url: '/v1/inbound/stock/draft/list',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { page: 1, limit: 99999 } // ★ 获取全量草稿数据
|
params: { page: 1, limit: 1 }
|
||||||
})
|
})
|
||||||
|
|
||||||
const drafts = res && res.items ? res.items : []
|
const total = res?.total || 0
|
||||||
|
|
||||||
if (!drafts || drafts.length === 0) {
|
if (total === 0) {
|
||||||
ElMessage.warning('没有找到未完成的盘点记录')
|
ElMessage.warning('没有找到未完成的盘点记录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 获取实际草稿数据用于恢复(分页加载,单次最多500条)
|
||||||
|
const fullRes = await request({
|
||||||
|
url: '/v1/inbound/stock/draft/list',
|
||||||
|
method: 'get',
|
||||||
|
params: { page: 1, limit: 500 }
|
||||||
|
})
|
||||||
|
const drafts = fullRes?.items || []
|
||||||
|
|
||||||
// 恢复已扫描的数据 - 简化为直接激活会话
|
// 恢复已扫描的数据 - 简化为直接激活会话
|
||||||
// 从草稿中获取 session_id
|
// 从草稿中获取 session_id
|
||||||
const sessionIds = [...new Set(drafts.map((d: any) => d.session_id))]
|
const sessionIds = [...new Set(drafts.map((d: any) => d.session_id))]
|
||||||
@ -975,75 +984,22 @@ const filteredVarianceList = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ★ 新增: 获取盘点清单数据(合并全量应盘物资 + 已盘点记录)
|
// ★ 新增: 获取盘点清单数据(合并全量应盘物资 + 已盘点记录)
|
||||||
// silent: 是否静默模式(不显示 loading,不报错)
|
// ★ 使用服务端 merged-list 端点(替代 99999 全量 + 前端 find())
|
||||||
const fetchInventoryList = async (silent = false) => {
|
const fetchInventoryList = async (silent = false) => {
|
||||||
if (!silent) listLoading.value = true
|
if (!silent) listLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 1. 获取已盘点记录
|
const res: any = await getDraftMergedList({
|
||||||
const res: any = await request({
|
session_id: currentSessionId.value,
|
||||||
url: '/v1/inbound/stock/draft/list',
|
keyword: listKeyword.value || undefined,
|
||||||
method: 'get',
|
status_filter: listStatusFilter.value || undefined,
|
||||||
params: {
|
page: listPage.value,
|
||||||
page: 1,
|
pageSize: listLimit.value
|
||||||
limit: 99999, // ★ 获取全量草稿数据
|
|
||||||
keyword: listKeyword.value,
|
|
||||||
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const scannedDrafts = res?.items || []
|
const data = res?.data || {}
|
||||||
// ★ 使用返回的 total 获取真实已盘数量,而不是受限的数组长度
|
listData.value = data.list || []
|
||||||
const totalScanned = res?.total || scannedDrafts.length
|
listTotalFiltered.value = data.total || 0
|
||||||
|
totalScannedCount.value = data.total_scanned || 0
|
||||||
// 保存全量草稿记录用于全局统计
|
|
||||||
allScannedDrafts.value = scannedDrafts
|
|
||||||
// 直接读取后端算好的去重已盘数
|
|
||||||
totalScannedCount.value = res?.total_scanned || 0
|
|
||||||
|
|
||||||
// 2. 使用全量应盘物资列表
|
|
||||||
// 对于每个应盘物资,检查是否有对应的盘点记录
|
|
||||||
let mergedData = allStockItems.value.map(item => {
|
|
||||||
// 查找对应的盘点记录
|
|
||||||
const draft = scannedDrafts.find((d: any) =>
|
|
||||||
d.source_table === item.source_table && d.stock_id === item.id
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: draft?.id || null,
|
|
||||||
stock_id: item.id,
|
|
||||||
source_table: item.source_table,
|
|
||||||
uniqueKey: `${item.source_table}_${item.id}`, // ★ 绝对唯一键,解决row-key冲突
|
|
||||||
sku: item.sku,
|
|
||||||
material_name: item.material_name,
|
|
||||||
spec_model: item.spec_model,
|
|
||||||
stock_qty: item.stock_qty, // 账面数(盲盘时隐藏)
|
|
||||||
quantity: draft?.quantity ?? draft?.qty_actual ?? 0, // 兼容后端字段名
|
|
||||||
diff_qty: draft ? (draft.quantity - item.stock_qty) : -item.stock_qty, // 差异
|
|
||||||
remark: draft?.remark || '',
|
|
||||||
warehouse_location: item.warehouse_location
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 关键词过滤
|
|
||||||
if (listKeyword.value) {
|
|
||||||
const kw = listKeyword.value.toLowerCase()
|
|
||||||
mergedData = mergedData.filter((item: any) =>
|
|
||||||
item.sku?.toLowerCase().includes(kw) ||
|
|
||||||
item.material_name?.toLowerCase().includes(kw)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 状态过滤
|
|
||||||
if (listStatusFilter.value === 'counted') {
|
|
||||||
mergedData = mergedData.filter((item: any) => item.quantity > 0)
|
|
||||||
} else if (listStatusFilter.value === 'uncounted') {
|
|
||||||
mergedData = mergedData.filter((item: any) => !item.quantity || item.quantity === 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 分页
|
|
||||||
listTotalFiltered.value = mergedData.length
|
|
||||||
const start = (listPage.value - 1) * listLimit.value
|
|
||||||
listData.value = mergedData.slice(start, start + listLimit.value)
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!silent) ElMessage.error('获取盘点清单失败')
|
if (!silent) ElMessage.error('获取盘点清单失败')
|
||||||
|
|||||||
Reference in New Issue
Block a user