diff --git a/inventory-backend/app/api/v1/inbound/inbound_summary.py b/inventory-backend/app/api/v1/inbound/inbound_summary.py index 44ffe8d..07fe2a3 100644 --- a/inventory-backend/app/api/v1/inbound/inbound_summary.py +++ b/inventory-backend/app/api/v1/inbound/inbound_summary.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request, jsonify # .material -> .base refactor checked +from flask import Blueprint, request, jsonify, send_file # .material -> .base refactor checked from app.services.inbound.inbound_summary_service import InboundSummaryService # 定义蓝图 @@ -33,3 +33,39 @@ def get_list(): # 生产环境建议记录详细日志 print(f"Inbound Summary Error: {str(e)}") return jsonify({'code': 500, 'msg': str(e)}), 500 + + +@bp.route('/export', methods=['GET']) +def export_data(): + """ + 导出入库记录 Excel + 支持筛选条件:keyword, start_date, end_date, source_type + """ + try: + # 获取参数 + keyword = request.args.get('keyword', '') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + source_type = request.args.get('source_type') + + # 调用导出服务 + file_stream = InboundSummaryService.export_excel( + keyword=keyword, + start_date=start_date, + end_date=end_date, + source_type=source_type + ) + + from datetime import datetime + now = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"入库记录_{now}.xlsx" + + return send_file( + file_stream, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + except Exception as e: + print(f"Inbound Summary Export Error: {str(e)}") + return jsonify({'code': 500, 'msg': str(e)}), 500 diff --git a/inventory-backend/app/services/inbound/base_service.py b/inventory-backend/app/services/inbound/base_service.py index 84ff6cc..fa67298 100644 --- a/inventory-backend/app/services/inbound/base_service.py +++ b/inventory-backend/app/services/inbound/base_service.py @@ -315,6 +315,8 @@ class MaterialBaseService: filter_conditions.append(column != value) elif operator == 'contains': filter_conditions.append(column.ilike(f'%{value}%')) + elif operator == 'not_contains': + filter_conditions.append(~column.ilike(f'%{value}%')) elif operator == 'ge': try: num_val = float(value) diff --git a/inventory-backend/app/services/inbound/inbound_summary_service.py b/inventory-backend/app/services/inbound/inbound_summary_service.py index ea8b340..b245ba6 100644 --- a/inventory-backend/app/services/inbound/inbound_summary_service.py +++ b/inventory-backend/app/services/inbound/inbound_summary_service.py @@ -6,6 +6,10 @@ from app.models.inbound.semi import StockSemi from app.models.inbound.product import StockProduct from app.models.base import MaterialBase import traceback +from io import BytesIO +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment +from openpyxl.utils import get_column_letter class InboundSummaryService: @@ -205,4 +209,174 @@ class InboundSummaryService: except Exception as e: print("【InboundSummaryService Error】:", str(e)) traceback.print_exc() + raise e + + @staticmethod + def export_excel(keyword=None, start_date=None, end_date=None, source_type=None): + """ + 导出入库记录 Excel + """ + try: + # 复用 get_list 的查询逻辑,但不分页(获取全部数据) + # 构建三个子查询 + q_buy = db.session.query( + StockBuy.id.label('id'), + StockBuy.base_id.label('base_id'), + StockBuy.sku.label('sku'), + StockBuy.in_date.label('inbound_date'), + StockBuy.in_quantity.label('in_qty'), + StockBuy.stock_quantity.label('current_qty'), + cast(StockBuy.supplier_name, String).label('source_info'), + StockBuy.status.label('orig_status'), + cast(getattr(StockBuy, 'batch_number', literal('')), String).label('batch_number'), + cast(getattr(StockBuy, 'serial_number', getattr(StockBuy, 'sn', literal(''))), String).label('serial_number'), + cast(literal('buy'), String).label('source_type') + ) + + q_semi = db.session.query( + StockSemi.id.label('id'), + StockSemi.base_id.label('base_id'), + StockSemi.sku.label('sku'), + StockSemi.production_date.label('inbound_date'), + StockSemi.in_quantity.label('in_qty'), + StockSemi.stock_quantity.label('current_qty'), + cast(StockSemi.production_manager, String).label('source_info'), + StockSemi.status.label('orig_status'), + cast(getattr(StockSemi, 'batch_number', literal('')), String).label('batch_number'), + cast(getattr(StockSemi, 'serial_number', getattr(StockSemi, 'sn', literal(''))), String).label('serial_number'), + cast(literal('semi'), String).label('source_type') + ) + + q_product = db.session.query( + StockProduct.id.label('id'), + StockProduct.base_id.label('base_id'), + StockProduct.sku.label('sku'), + StockProduct.production_date.label('inbound_date'), + StockProduct.in_quantity.label('in_qty'), + StockProduct.stock_quantity.label('current_qty'), + cast(StockProduct.production_manager, String).label('source_info'), + StockProduct.status.label('orig_status'), + cast(getattr(StockProduct, 'batch_number', literal('')), String).label('batch_number'), + cast(getattr(StockProduct, 'serial_number', getattr(StockProduct, 'sn', literal(''))), String).label('serial_number'), + cast(literal('product'), String).label('source_type') + ) + + combined_query = union_all(q_buy, q_semi, q_product) + cte = combined_query.subquery() + + query = db.session.query( + cte, + MaterialBase.name.label('material_name'), + MaterialBase.spec_model.label('spec_model'), + MaterialBase.category.label('category'), + MaterialBase.material_type.label('material_type') + ).outerjoin( + MaterialBase, cte.c.base_id == MaterialBase.id + ) + + # 过滤条件 + if keyword: + rule = or_( + cte.c.sku.ilike(f'%{keyword}%'), + cte.c.source_info.ilike(f'%{keyword}%'), + cte.c.batch_number.ilike(f'%{keyword}%'), + cte.c.serial_number.ilike(f'%{keyword}%'), + MaterialBase.name.ilike(f'%{keyword}%'), + MaterialBase.spec_model.ilike(f'%{keyword}%') + ) + query = query.filter(rule) + + if start_date and end_date: + query = query.filter(cte.c.inbound_date.between(start_date, end_date)) + + if source_type: + query = query.filter(cte.c.source_type == source_type) + + # 排序 + query = query.order_by(desc(cte.c.inbound_date), asc(cte.c.sku)) + + # 获取全部数据 + rows = query.all() + + # 创建 Excel + wb = Workbook() + ws = wb.active + ws.title = "入库记录" + + # 表头样式 + 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") + + # 写表头 + headers = ['SKU', '物品名称', '规格型号', '分类', '入库来源', '入库/生产日期', '入库数量', '批次/序列号', '供应商/负责人', '当前状态'] + 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 + + # 写数据 + type_map = {'buy': '采购入库', 'semi': '半成品生产', 'product': '成品完工'} + + for row_idx, row in enumerate(rows, 2): + date_str = "" + if row.inbound_date: + try: + date_str = row.inbound_date.strftime('%Y-%m-%d') + except Exception: + date_str = str(row.inbound_date) + + in_qty = float(row.in_qty) if row.in_qty is not None else 0.0 + current_qty = float(row.current_qty) if row.current_qty is not None else 0.0 + + final_status = row.orig_status + if current_qty <= 0: + final_status = "已出库" + elif current_qty < in_qty: + final_status = "部分出库" + + b_num = row.batch_number or "" + s_num = row.serial_number or "" + if b_num and s_num and b_num != s_num: + display_batch_sn = f"{b_num}/{s_num}" + elif b_num: + display_batch_sn = b_num + elif s_num: + display_batch_sn = s_num + else: + display_batch_sn = "-" + + ws.cell(row=row_idx, column=1, value=row.sku or "") + ws.cell(row=row_idx, column=2, value=row.material_name or "未知物品") + ws.cell(row=row_idx, column=3, value=row.spec_model or "") + ws.cell(row=row_idx, column=4, value=row.category or "") + ws.cell(row=row_idx, column=5, value=type_map.get(row.source_type, "未知类型")) + ws.cell(row=row_idx, column=6, value=date_str) + ws.cell(row=row_idx, column=7, value=in_qty) + ws.cell(row=row_idx, column=8, value=display_batch_sn) + ws.cell(row=row_idx, column=9, value=row.source_info or "") + ws.cell(row=row_idx, column=10, value=final_status) + + # 自动调整列宽 + for col in range(1, len(headers) + 1): + max_length = 0 + column_letter = get_column_letter(col) + for row in range(2, len(rows) + 2): + cell_value = ws.cell(row=row, column=col).value + if cell_value: + max_length = max(max_length, len(str(cell_value))) + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 输出到字节流 + file_stream = BytesIO() + wb.save(file_stream) + file_stream.seek(0) + + return file_stream + + except Exception as e: + print("【InboundSummaryService Export Error】:", str(e)) + traceback.print_exc() raise e \ No newline at end of file diff --git a/inventory-web/src/api/inbound/inbound_summary.ts b/inventory-web/src/api/inbound/inbound_summary.ts index 30f93f1..6c14610 100644 --- a/inventory-web/src/api/inbound/inbound_summary.ts +++ b/inventory-web/src/api/inbound/inbound_summary.ts @@ -15,4 +15,13 @@ export function getInboundSummaryList(params: InboundSummaryQuery) { method: 'get', params }) +} + +export function exportInboundSummary(params: any) { + return request({ + url: '/v1/inbound/summary/export', + method: 'get', + params, + responseType: 'blob' + }) } \ No newline at end of file diff --git a/inventory-web/src/views/material/list.vue b/inventory-web/src/views/material/list.vue index 464a31d..30b8f2f 100644 --- a/inventory-web/src/views/material/list.vue +++ b/inventory-web/src/views/material/list.vue @@ -657,6 +657,7 @@ const operatorOptions = ref([ { value: 'eq', label: '等于' }, { value: 'ne', label: '不等于' }, { value: 'contains', label: '包含' }, + { value: 'not_contains', label: '不包含' }, { value: 'ge', label: '大于等于' }, { value: 'le', label: '小于等于' } ]); diff --git a/inventory-web/src/views/stock/inbound/buy.vue b/inventory-web/src/views/stock/inbound/buy.vue index c42924c..a83212e 100644 --- a/inventory-web/src/views/stock/inbound/buy.vue +++ b/inventory-web/src/views/stock/inbound/buy.vue @@ -880,6 +880,7 @@ const operatorOptions = ref([ { value: 'eq', label: '等于' }, { value: 'ne', label: '不等于' }, { value: 'contains', label: '包含' }, + { value: 'not_contains', label: '不包含' }, { value: 'ge', label: '大于等于' }, { value: 'le', label: '小于等于' } ]) diff --git a/inventory-web/src/views/stock/inbound/inbound_summary.vue b/inventory-web/src/views/stock/inbound/inbound_summary.vue index e1a35e3..3cd79e3 100644 --- a/inventory-web/src/views/stock/inbound/inbound_summary.vue +++ b/inventory-web/src/views/stock/inbound/inbound_summary.vue @@ -28,6 +28,10 @@ @change="handleFilter" /> 查询 + + + 导出Excel + import { ref, reactive, onMounted } from 'vue' -import { getInboundSummaryList } from '@/api/inbound/inbound_summary' +import { getInboundSummaryList, exportInboundSummary } from '@/api/inbound/inbound_summary' import { useUserStore } from '@/stores/user' +import { ElMessage } from 'element-plus' const userStore = useUserStore() const list = ref([]) const total = ref(0) const loading = ref(false) +const exportLoading = ref(false) const listQuery = reactive({ page: 1, @@ -147,6 +153,48 @@ const handleFilter = () => { fetchData() } +// 导出 Excel +const handleExport = () => { + exportLoading.value = true + const params = { + keyword: listQuery.keyword, + source_type: listQuery.source_type, + start_date: listQuery.dateRange ? listQuery.dateRange[0] : null, + end_date: listQuery.dateRange ? listQuery.dateRange[1] : null + } + + exportInboundSummary(params) + .then((response: any) => { + const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + const hour = String(now.getHours()).padStart(2, '0') + const minute = String(now.getMinutes()).padStart(2, '0') + const second = String(now.getSeconds()).padStart(2, '0') + const filename = `入库记录_${year}${month}${day}_${hour}${minute}${second}.xlsx` + + link.setAttribute('download', filename) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + ElMessage.success('导出成功') + }) + .catch((err: any) => { + console.error('导出失败', err) + ElMessage.error('导出失败') + }) + .finally(() => { + exportLoading.value = false + }) +} + // 来源类型的 Tag 颜色 const getSourceTag = (type: string) => { if (type === 'buy') return 'success' // 绿色