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' // 绿色