Compare commits

5 Commits

11 changed files with 317 additions and 20 deletions

View File

@ -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

View File

@ -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)
@ -636,7 +638,7 @@ class MaterialBaseService:
# 2.1 采购库存 (StockBuy)
query_buy = db.session.query(StockBuy, MaterialBase).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
)
).filter(StockBuy.stock_quantity > 0)
for cond in filter_conditions:
query_buy = query_buy.filter(cond)
list_buy = query_buy.all()
@ -644,7 +646,7 @@ class MaterialBaseService:
# 2.2 半成品库存 (StockSemi)
query_semi = db.session.query(StockSemi, MaterialBase).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
)
).filter(StockSemi.stock_quantity > 0)
for cond in filter_conditions:
query_semi = query_semi.filter(cond)
list_semi = query_semi.all()
@ -652,7 +654,7 @@ class MaterialBaseService:
# 2.3 成品库存 (StockProduct)
query_product = db.session.query(StockProduct, MaterialBase).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
)
).filter(StockProduct.stock_quantity > 0)
for cond in filter_conditions:
query_product = query_product.filter(cond)
list_product = query_product.all()

View File

@ -403,6 +403,8 @@ class BuyInboundService:
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)

View File

@ -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:
@ -110,6 +114,12 @@ class InboundSummaryService:
)
query = query.filter(rule)
# 日期补全:解决零点截断问题
if end_date and len(str(end_date).strip()) == 10:
end_date = f"{str(end_date).strip()} 23:59:59"
if start_date and len(str(start_date).strip()) == 10:
start_date = f"{str(start_date).strip()} 00:00:00"
if start_date and end_date:
query = query.filter(cte.c.inbound_date.between(start_date, end_date))
@ -206,3 +216,179 @@ class InboundSummaryService:
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 end_date and len(str(end_date).strip()) == 10:
end_date = f"{str(end_date).strip()} 23:59:59"
if start_date and len(str(start_date).strip()) == 10:
start_date = f"{str(start_date).strip()} 00:00:00"
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

View File

@ -198,6 +198,12 @@ class OutboundService:
支持跨表搜索单号、领用人、SKU、物料名称、规格型号
search_type: all, no, name, sku, material_name, spec_model
"""
# 日期补全:解决零点截断问题
if end_date and len(str(end_date).strip()) == 10:
end_date = f"{str(end_date).strip()} 23:59:59"
if start_date and len(str(start_date).strip()) == 10:
start_date = f"{str(start_date).strip()} 00:00:00"
# 1. 构建基础查询
# 如果有关键词,需要联表搜索物料名称和规格型号
if keyword:
@ -403,11 +409,12 @@ class OutboundService:
'items': []
}
# --- 查询物品详细信息 (名称, 规格, 类型, 类别) ---
# --- 查询物品详细信息 (名称, 规格, 类型, 类别, 批号/SN) ---
item_name = "未知物品"
item_spec = ""
item_cat = ""
item_type = ""
batch_sn = "-"
ModelClass = model_map.get(d.source_table)
if ModelClass and d.stock_id:
@ -415,7 +422,10 @@ class OutboundService:
# 生产环境建议优化为预加载或批量查询
try:
stock_item = ModelClass.query.get(d.stock_id)
if stock_item and stock_item.base:
if stock_item:
# 获取批号/序列号用于追溯
batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-'
if stock_item.base:
item_name = stock_item.base.name
item_spec = stock_item.base.spec_model
item_cat = stock_item.base.category
@ -445,7 +455,8 @@ class OutboundService:
'material_type': item_type,
'quantity': qty,
'unit_price': price,
'subtotal': subtotal
'subtotal': subtotal,
'batch_sn': batch_sn
})
# 4. 排序输出

View File

@ -176,7 +176,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.9(4.3盘库错误修改
当前版本:V3.10(4.7部署
</span>
</footer>

View File

@ -16,3 +16,12 @@ export function getInboundSummaryList(params: InboundSummaryQuery) {
params
})
}
export function exportInboundSummary(params: any) {
return request({
url: '/v1/inbound/summary/export',
method: 'get',
params,
responseType: 'blob'
})
}

View File

@ -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: '小于等于' }
]);

View File

@ -53,6 +53,7 @@
<el-table-column v-if="hasColumnPermission('material_type')" prop="material_type" label="类型" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('category')" prop="category" label="类别" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('spec_model')" prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="batch_sn" label="批号/SN" min-width="120" />
<el-table-column v-if="hasColumnPermission('quantity')" prop="quantity" label="数量" width="100" />
<el-table-column v-if="hasColumnPermission('unit_price')" prop="unit_price" label="单价" width="120">

View File

@ -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: '小于等于' }
])

View File

@ -28,6 +28,10 @@
@change="handleFilter"
/>
<el-button type="primary" class="filter-item" style="margin-left: 10px;" @click="handleFilter">查询</el-button>
<el-button type="success" plain class="filter-item" style="margin-left: 10px;" @click="handleExport" :loading="exportLoading">
导出Excel
</el-button>
</div>
<el-table
@ -100,14 +104,16 @@
<script setup lang="ts">
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' // 绿色