diff --git a/inventory-backend/app/api/v1/inbound/base.py b/inventory-backend/app/api/v1/inbound/base.py index 2d8b900..dfcf54f 100644 --- a/inventory-backend/app/api/v1/inbound/base.py +++ b/inventory-backend/app/api/v1/inbound/base.py @@ -1,7 +1,9 @@ # 文件路径: app/api/v1/inbound/base.py -from flask import Blueprint, request, jsonify + +from flask import Blueprint, request, jsonify, send_file from app.services.inbound.base_service import MaterialBaseService import traceback +import datetime inbound_base_bp = Blueprint('stock_base', __name__) @@ -26,12 +28,13 @@ def search_base(): @inbound_base_bp.route('/list', methods=['GET']) def get_list(): try: - page = request.args.get('pageNum', 1, type=int) # 前端传的是 pageNum + page = request.args.get('pageNum', 1, type=int) limit = request.args.get('pageSize', 10, type=int) # 构造筛选条件 filters = { 'keyword': request.args.get('keyword', ''), + 'company': request.args.get('company', ''), 'category': request.args.get('category', ''), 'type': request.args.get('type', ''), 'isEnabled': request.args.get('isEnabled', None) @@ -45,7 +48,7 @@ def get_list(): # ============================================================================== -# 2.1 选项接口 (GET /api/v1/inbound/base/options) [新增] +# 2.1 选项接口 (GET /api/v1/inbound/base/options) # ============================================================================== @inbound_base_bp.route('/options', methods=['GET']) def get_options(): @@ -57,9 +60,45 @@ def get_options(): return jsonify({"code": 500, "msg": str(e)}), 500 +# ============================================================================== +# 2.2 导出接口 (GET /api/v1/inbound/base/export) +# ============================================================================== +@inbound_base_bp.route('/export', methods=['GET']) +def export_data(): + try: + # 获取筛选条件 + filters = { + 'keyword': request.args.get('keyword', ''), + 'company': request.args.get('company', ''), + 'category': request.args.get('category', ''), + 'type': request.args.get('type', ''), + 'isEnabled': request.args.get('isEnabled', None) + } + + # 生成 Excel 文件流 + file_stream = MaterialBaseService.export_excel(filters) + + # 生成文件名:库存统计+年月日+时分秒 (北京时间 UTC+8) + # 简单处理:UTC时间 + 8小时 + beijing_time = datetime.datetime.utcnow() + datetime.timedelta(hours=8) + filename = f"库存统计_{beijing_time.strftime('%Y%m%d_%H%M%S')}.xlsx" + + # 发送文件 + # 注意:download_name 仅在较新 Flask 版本有效,旧版本可能需要手动 header, + # 但通常浏览器下载名由前端 Blob 处理或 Content-Disposition 决定。 + return send_file( + file_stream, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + except Exception as e: + traceback.print_exc() + return jsonify({"code": 500, "msg": f"导出失败: {str(e)}"}), 500 + + # ============================================================================== # 3. 新增接口 (POST /api/v1/inbound/base/) -# 注意:前端 material_base.ts 可能会请求 / 或 /add,这里统一匹配 # ============================================================================== @inbound_base_bp.route('/', methods=['POST']) def create(): diff --git a/inventory-backend/app/services/inbound/base_service.py b/inventory-backend/app/services/inbound/base_service.py index 635ebaa..97bc2a9 100644 --- a/inventory-backend/app/services/inbound/base_service.py +++ b/inventory-backend/app/services/inbound/base_service.py @@ -4,11 +4,16 @@ from app.extensions import db from app.models.base import MaterialBase from app.models.inbound.buy import StockBuy from app.models.inbound.semi import StockSemi -# from app.models.inbound.product import StockProduct +from app.models.inbound.product import StockProduct # from app.models.inbound.service import StockService -from sqlalchemy import or_ +from sqlalchemy import or_, and_ import traceback import json +import io +import datetime +# 需要 pip install openpyxl +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, Border, Side, PatternFill class MaterialBaseService: @@ -91,7 +96,7 @@ class MaterialBaseService: for x in items: # 1. 获取库存数 (兼容不同字段名) - q = getattr(x, 'stock_quantity', getattr(x, 'actual_quantity', getattr(x, 'quantity', 0))) + q = getattr(x, 'stock_quantity', getattr(x, 'in_quantity', 0)) # 优先取库存,其次入库 # 2. 获取可用数 a = getattr(x, 'available_quantity', q) @@ -137,7 +142,6 @@ class MaterialBaseService: query = query.filter_by(is_enabled=is_active) # [修改3] 默认排序方式改为按 spec_model 排序 - # 如果需要更复杂的“/前内容”排序,通常直接按字符串排序也能满足前缀分组的需求 pagination = query.order_by(MaterialBase.spec_model.asc()).paginate(page=page, per_page=limit, error_out=False) @@ -280,14 +284,16 @@ class MaterialBaseService: buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count() semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count() + prod_usage_count = StockProduct.query.filter_by(base_id=m_id).count() - total_usage = buy_usage_count + semi_usage_count + total_usage = buy_usage_count + semi_usage_count + prod_usage_count if total_usage > 0: raise ValueError( f"无法删除:该基础物料正被使用中。\n" f"- 采购库存记录: {buy_usage_count} 条\n" f"- 半成品库存记录: {semi_usage_count} 条\n" + f"- 成品库存记录: {prod_usage_count} 条\n" f"请先清理相关库存或仅‘禁用’此条目。" ) @@ -298,4 +304,235 @@ class MaterialBaseService: except Exception as e: db.session.rollback() print(f"删除基础信息失败: {e}") + raise e + + # ============================================================================== + # [核心修改] 统一资产统计导出 + # ============================================================================== + @staticmethod + def export_excel(filters=None): + """ + 全口径资产统计报表: + 逻辑:先查库存表 (Buy, Semi, Product),关联基础信息,只导出实际存在的库存记录。 + """ + try: + # 1. 构造基础信息的筛选条件 (用于过滤库存) + filter_conditions = [] + if filters: + if filters.get('keyword'): + kw = f"%{filters['keyword']}%" + filter_conditions.append(or_( + MaterialBase.name.ilike(kw), + MaterialBase.common_name.ilike(kw), + MaterialBase.spec_model.ilike(kw), + MaterialBase.company_name.ilike(kw) + )) + if filters.get('company'): + filter_conditions.append(MaterialBase.company_name == filters['company']) + if filters.get('category'): + filter_conditions.append(MaterialBase.category == filters['category']) + if filters.get('type'): + filter_conditions.append(MaterialBase.material_type == filters['type']) + if filters.get('isEnabled') is not None: + is_active = bool(int(filters['isEnabled'])) + filter_conditions.append(MaterialBase.is_enabled == is_active) + + # 2. 分别查询三个库存表,并 Join MaterialBase 进行筛选 + # 2.1 采购库存 (StockBuy) + query_buy = db.session.query(StockBuy, MaterialBase).join( + MaterialBase, StockBuy.base_id == MaterialBase.id + ) + for cond in filter_conditions: + query_buy = query_buy.filter(cond) + list_buy = query_buy.all() + + # 2.2 半成品库存 (StockSemi) + query_semi = db.session.query(StockSemi, MaterialBase).join( + MaterialBase, StockSemi.base_id == MaterialBase.id + ) + for cond in filter_conditions: + query_semi = query_semi.filter(cond) + list_semi = query_semi.all() + + # 2.3 成品库存 (StockProduct) + query_product = db.session.query(StockProduct, MaterialBase).join( + MaterialBase, StockProduct.base_id == MaterialBase.id + ) + for cond in filter_conditions: + query_product = query_product.filter(cond) + list_product = query_product.all() + + # 3. 数据整合 + all_rows = [] + + # 处理采购件 + for stock, base in list_buy: + # 价格计算 + unit_price = float(stock.unit_price or 0) + tax_rate = float(stock.tax_rate or 0) + price_incl = unit_price * (1 + tax_rate / 100.0) + qty = float(stock.stock_quantity or 0) + + # 计算不含税总价 = 数量 * 不含税单价 + total_val_excl = qty * unit_price + # 计算含税总价 = 数量 * 含税单价 + total_val_incl = qty * price_incl + + ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku + + all_rows.append({ + "base": base, + "type_name": "采购件", + "ident": ident, + "loc": stock.warehouse_location, + "source": stock.supplier_name, + "date": stock.in_date, + "qty": qty, + "avail": float(stock.available_quantity or 0), + "price_excl": unit_price, + "total_val_excl": total_val_excl, # [新增] + "tax": tax_rate, + "price_incl": price_incl, + "total_val": total_val_incl + }) + + # 处理半成品 + for stock, base in list_semi: + cost = float(stock.raw_material_cost or 0) + float(stock.manual_cost or 0) + qty = float(stock.stock_quantity or 0) + + # 半成品不含税总价 = 数量 * 成本 + total_val_excl = qty * cost + # 含税总价同上 (税率0) + total_val_incl = qty * cost + + ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku + + all_rows.append({ + "base": base, + "type_name": "半成品", + "ident": ident, + "loc": stock.warehouse_location, + "source": stock.production_manager, + "date": stock.production_date, + "qty": qty, + "avail": float(stock.available_quantity or 0), + "price_excl": cost, + "total_val_excl": total_val_excl, # [新增] + "tax": 0.0, + "price_incl": cost, + "total_val": total_val_incl + }) + + # 处理成品 + for stock, base in list_product: + cost = float(stock.raw_material_cost or 0) + float(stock.manual_cost or 0) + qty = float(stock.stock_quantity or 0) + + total_val_excl = qty * cost + total_val_incl = qty * cost + + ident = stock.serial_number or stock.barcode or stock.sku + + all_rows.append({ + "base": base, + "type_name": "成品", + "ident": ident, + "loc": stock.warehouse_location, + "source": stock.production_manager, + "date": stock.production_date, + "qty": qty, + "avail": float(stock.available_quantity or 0), + "price_excl": cost, + "total_val_excl": total_val_excl, # [新增] + "tax": 0.0, + "price_incl": cost, + "total_val": total_val_incl + }) + + # 4. 排序:按公司 -> 规格型号 -> 基础ID -> 批号 排序 + all_rows.sort(key=lambda x: ( + x['base'].company_name or "", + x['base'].spec_model or "", + x['base'].id, + x['ident'] or "" + )) + + # 5. 生成 Excel + wb = Workbook() + ws = wb.active + ws.title = "库存统计" + + # 表头 [修改] 增加 "资产总额 (不含税)" + headers = [ + "所属公司", "资产名称", "规格型号", "物料类型", + "类别一级", "类别二级", "类别三级", "类别四级", "类别五级", + "计量单位", + "库存性质", "唯一标识码 (批号/SN)", "仓库位置", + "资产来源", "入库/生产日期", + "库存数量", "可用数量", + "单价/成本 (不含税)", "资产总额 (不含税)", "税率 (%)", "单价/成本 (含税)", "资产总额 (含税)" + ] + ws.append(headers) + + # 样式 + header_fill = PatternFill(start_color="D7E4BC", end_color="D7E4BC", fill_type="solid") + border_style = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), + bottom=Side(style='thin')) + + for cell in ws[1]: + cell.font = Font(bold=True, name='微软雅黑') + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.fill = header_fill + cell.border = border_style + + # 写入数据 + for r in all_rows: + base = r['base'] + # 类别拆分 + cat_parts = (base.category or "").split('/') + while len(cat_parts) < 5: + cat_parts.append("") + + # 日期格式化 + date_str = r['date'].strftime('%Y-%m-%d') if isinstance(r['date'], datetime.date) else "" + + row_val = [ + base.company_name, + base.name, + base.spec_model, + base.material_type, + cat_parts[0], cat_parts[1], cat_parts[2], cat_parts[3], cat_parts[4], + base.unit, + r['type_name'], + r['ident'], + r['loc'], + r['source'], + date_str, + r['qty'], + r['avail'], + r['price_excl'], + r['total_val_excl'], # [新增] 对应列 + r['tax'], + r['price_incl'], + r['total_val'] + ] + ws.append(row_val) + + # 列宽调整 + dims = {} + for row in ws.rows: + for cell in row: + if cell.value: + dims[cell.column_letter] = max((dims.get(cell.column_letter, 0), len(str(cell.value)))) + for col, value in dims.items(): + ws.column_dimensions[col].width = min(value + 2, 30) + + output = io.BytesIO() + wb.save(output) + output.seek(0) + return output + + except Exception as e: + traceback.print_exc() raise e \ No newline at end of file diff --git a/inventory-backend/requirements.txt b/inventory-backend/requirements.txt index f3a43a7..eec2410 100644 --- a/inventory-backend/requirements.txt +++ b/inventory-backend/requirements.txt @@ -13,4 +13,6 @@ python-barcode>=0.14.0 # [新增] 二维码生成库 (标签打印必需,包含PIL支持) qrcode[pil]>=7.4.2 # [新增] 必须添加,用于处理 token 登录 -Flask-JWT-Extended==4.6.0 \ No newline at end of file +Flask-JWT-Extended==4.6.0 +# [新增] Excel 处理库 (解决 No module named 'openpyxl' 报错) +openpyxl>=3.1.2 \ No newline at end of file diff --git a/inventory-web/src/api/material_base.ts b/inventory-web/src/api/material_base.ts index d2831cc..a1b0ba8 100644 --- a/inventory-web/src/api/material_base.ts +++ b/inventory-web/src/api/material_base.ts @@ -9,9 +9,7 @@ export function listMaterialBase(params: any) { }) } -// ========================================== -// 1.1 获取基础信息筛选选项 (所有类别/类型) [新增] -// ========================================== +// 1.1 获取选项 export function getMaterialBaseOptions() { return request({ url: '/inbound/base/options', @@ -19,7 +17,17 @@ export function getMaterialBaseOptions() { }) } -// 2. 新增基础信息 +// 1.2 [新增] 导出全口径资产统计表 +export function exportAssetStatistics(params: any) { + return request({ + url: '/inbound/base/export', + method: 'get', + params, + responseType: 'blob' // 关键:必须声明为 blob 处理文件流 + }) +} + +// 2. 新增 export function addMaterialBase(data: any) { return request({ url: '/inbound/base/', @@ -28,7 +36,7 @@ export function addMaterialBase(data: any) { }) } -// 3. 修改基础信息 (包含状态启用/禁用) +// 3. 修改 export function updateMaterialBase(data: any) { return request({ url: `/inbound/base/${data.id}`, @@ -37,18 +45,10 @@ export function updateMaterialBase(data: any) { }) } -// 4. 删除基础信息 +// 4. 删除 export function delMaterialBase(id: number) { return request({ url: `/inbound/base/${id}`, method: 'delete' }) -} - -// 5. 获取详情 (可选,用于编辑回显) -export function getMaterialBase(id: number) { - return request({ - url: `/inbound/base/${id}`, - method: 'get' - }) } \ 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 a3132ad..629d1c1 100644 --- a/inventory-web/src/views/material/list.vue +++ b/inventory-web/src/views/material/list.vue @@ -67,6 +67,10 @@
+ + 导出库存统计 + + 新增 @@ -412,8 +416,8 @@