From 54ea476206dbf8e6fbe87298202362c836612c45 Mon Sep 17 00:00:00 2001 From: DXC Date: Wed, 18 Mar 2026 11:10:54 +0800 Subject: [PATCH] refactor: unify variance calculation and implement backend Excel export with borrowed assets sheet --- inventory-backend/app/api/v1/inbound/stock.py | 185 +++++++++++++++++- .../src/views/stock/stocktake/index.vue | 74 +------ 2 files changed, 184 insertions(+), 75 deletions(-) diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 8e38ba4..aa6cae4 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -1,14 +1,18 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, send_file from app.extensions import db # ★★★ 修复点:必须引入 datetime,否则下方更新时间时会报错 500 ★★★ from datetime import datetime from app.utils.decorators import permission_required 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 def _normalize_user_id(user_id): @@ -144,6 +148,10 @@ def add_draft(): """ 扫码同步 (支持更新数量) 如果 session_id 不存在则创建新的会话 + + 差异计算逻辑调整: + - adjusted_stock_qty = 账面总库存 - 借出未还数量 + - diff_qty = 实盘数量 - adjusted_stock_qty """ try: data = request.json @@ -164,7 +172,21 @@ def add_draft(): 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(TransBorrow.quantity - TransBorrow.returned_quantity) + ).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 # 查找是否已存在 draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid, session_id=session_id).first() @@ -173,8 +195,8 @@ def add_draft(): # 如果已存在,更新数量和时间 draft.quantity = quantity draft.scan_time = datetime.now() - draft.stock_qty = stock_qty - draft.diff_qty = quantity - stock_qty + draft.stock_qty = adjusted_stock_qty + draft.diff_qty = quantity - adjusted_stock_qty draft.source_table = source_table draft.stock_id = stock_id else: @@ -184,8 +206,8 @@ def add_draft(): uuid=uuid, quantity=quantity, session_id=session_id, - stock_qty=stock_qty, - diff_qty=quantity - stock_qty, + stock_qty=adjusted_stock_qty, + diff_qty=quantity - adjusted_stock_qty, source_table=source_table, stock_id=stock_id ) @@ -195,7 +217,9 @@ def add_draft(): return jsonify({ "message": "Saved", "session_id": session_id, - "draft_id": draft.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}") @@ -460,3 +484,152 @@ def print_stocktake(): 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. 外借在用资产明细 (未归还的借出记录) + """ + try: + # 创建工作簿 + 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': '-'} + + if not stock: + return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-'} + + # 尝试从MaterialBase获取名称 + material = MaterialBase.query.filter_by(sku=stock.sku).first() + return { + 'name': material.name if material else stock.sku, + 'sku': stock.sku, + 'spec': stock.spec_model or stock.standard or '-', + 'unit': stock.unit or '个' + } + + 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("盘点差异明细") + diff_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间"] + set_header_row(ws1, diff_headers) + + diff_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0).all() + for row_idx, draft in enumerate(diff_drafts, 2): + mat_info = get_material_info(draft.source_table, draft.stock_id) + ws1.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border + ws1.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border + ws1.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border + ws1.cell(row=row_idx, column=4, value=draft.source_table).border = thin_border + ws1.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border + ws1.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border + ws1.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border + ws1.cell(row=row_idx, column=8, value=draft.user_id or '').border = thin_border + ws1.cell(row=row_idx, column=9, value=str(draft.scan_time)[:19] if draft.scan_time else '').border = thin_border + + # ===== Sheet 2: 账实相符明细 ===== + ws2 = wb.create_sheet("账实相符明细") + normal_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间"] + set_header_row(ws2, normal_headers) + + normal_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty == 0).all() + for row_idx, draft in enumerate(normal_drafts, 2): + mat_info = get_material_info(draft.source_table, draft.stock_id) + 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=draft.source_table).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=draft.user_id or '').border = thin_border + ws2.cell(row=row_idx, column=9, value=str(draft.scan_time)[:19] if draft.scan_time else '').border = thin_border + + # ===== Sheet 3: 外借在用资产明细 ===== + ws3 = wb.create_sheet("外借在用资产明细") + borrow_headers = ["借出单号", "借用人", "物料名称", "SKU", "规格型号", "借出总数", "已还数量", "待还数量", "借出时间", "预计归还时间"] + set_header_row(ws3, 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 + + ws3.cell(row=row_idx, column=1, value=borrow.borrow_no or '').border = thin_border + ws3.cell(row=row_idx, column=2, value=borrow.borrower_name or '').border = thin_border + ws3.cell(row=row_idx, column=3, value=mat_info['name']).border = thin_border + ws3.cell(row=row_idx, column=4, value=mat_info['sku']).border = thin_border + ws3.cell(row=row_idx, column=5, value=mat_info['spec']).border = thin_border + ws3.cell(row=row_idx, column=6, value=total_qty).border = thin_border + ws3.cell(row=row_idx, column=7, value=returned_qty).border = thin_border + ws3.cell(row=row_idx, column=8, value=pending_qty).border = thin_border + ws3.cell(row=row_idx, column=9, value=str(borrow.borrow_time)[:19] if borrow.borrow_time else '').border = thin_border + ws3.cell(row=row_idx, column=10, value='无限期' if not borrow.expected_return_time else str(borrow.expected_return_time)[:10]).border = thin_border + + # 调整列宽 + for ws in [ws1, ws2, ws3]: + 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 diff --git a/inventory-web/src/views/stock/stocktake/index.vue b/inventory-web/src/views/stock/stocktake/index.vue index 6f89741..4fbc182 100644 --- a/inventory-web/src/views/stock/stocktake/index.vue +++ b/inventory-web/src/views/stock/stocktake/index.vue @@ -356,7 +356,6 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close } from '@element-plus/icons-vue' import request from '@/utils/request' import { useUserStore } from '@/stores/user' -import * as XLSX from 'xlsx' const userStore = useUserStore() const currentUser = userStore.username || 'admin' @@ -748,75 +747,12 @@ const closeOverlays = () => { showQtyDialog.value = false } -// --- 导出 Excel 逻辑 --- +// --- 导出 Excel 逻辑 (调用后端API) --- const exportToExcel = () => { - try { - // 1. 已盘点 Sheet - const scannedData = allData.value.filter(i => i.scanned).map(item => { - const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : '' - const borrowedQty = borrowedQuantities.value[key] || 0 - const actualTotal = item.qty_actual + borrowedQty - const diff = actualTotal - item.qty_stock - const result = diff === 0 ? '正常' : diff < 0 ? '盘亏/差异' : '盘盈' - return { - '物品名称': item.name, - '类型': item.type || item.material_type || '-', - '类别': item.category || '-', - '规格型号': item.spec_model || item.standard || '-', // ★ 双重保险 - 'SKU': item.sku, - '批次/SN': item.serial_number || item.batch_no || '-', - '单位': item.unit || '个', - '单价': item.price || item.unit_price || 0, - '账面库存': parseFloat(item.qty_stock as any), - '实盘数量': item.qty_actual, - '借出未还数量': borrowedQty, - '盘点结果': result, - '差异数': diff - } - }) - - // 2. 未盘点 Sheet - const missingData = allData.value.filter(i => !i.scanned).map(item => { - const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : '' - const borrowedQty = borrowedQuantities.value[key] || 0 - return { - '物品名称': item.name, - '类型': item.type || item.material_type || '-', - '类别': item.category || '-', - '规格型号': item.spec_model || item.standard || '-', // ★ 双重保险 - 'SKU': item.sku, - '批次/SN': item.serial_number || item.batch_no || '-', - '单位': item.unit || '个', - '单价': item.price || item.unit_price || 0, - '账面库存': parseFloat(item.qty_stock as any), - '借出未还数量': borrowedQty, - '状态': '未盘点' - } - }) - - const wb = XLSX.utils.book_new() - const ws1 = XLSX.utils.json_to_sheet(scannedData) - const ws2 = XLSX.utils.json_to_sheet(missingData) - - const wscols = [ - {wch: 20}, {wch: 10}, {wch: 10}, {wch: 15}, - {wch: 15}, {wch: 15}, {wch: 5}, {wch: 8}, - {wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}, {wch: 8} - ] - ws1['!cols'] = wscols - ws2['!cols'] = wscols - - XLSX.utils.book_append_sheet(wb, ws1, "已盘点明细") - XLSX.utils.book_append_sheet(wb, ws2, "未盘点明细") - - const fileName = `库存盘点报告_${new Date().toISOString().slice(0,10)}.xlsx` - XLSX.writeFile(wb, fileName) - - ElMessage.success('Excel 报表已生成') - } catch (e) { - console.error(e) - ElMessage.error('导出失败,请检查 xlsx 插件是否安装') - } + // 调用后端API下载盘点报告 + const baseUrl = import.meta.env.VITE_APP_BASE_API || '' + window.open(`${baseUrl}/v1/inbound/stock/export-stocktake`, '_blank') + ElMessage.success('正在下载盘点报告...') } const filteredList = computed(() => {