feat: Excel批量导入后端 — 模板下载+预览验证+执行导入

## import_service.py (新增)
- generate_template(): 基础信息/BOM Excel模板生成(带样例数据)
- parse_boolean(): 是/否/True/False 稳健布尔解析
- preview_material/bom(): Dry-Run解析+验证(重复/存在性/必填)
- BOM ffill: 父件列自动向下填充(支持合并单元格UX)
- execute_material/bom_import(): 事务包裹批量写入

## import_api.py (新增)
- GET /api/v1/import/template?type=material|bom
- POST /api/v1/import/preview (文件→预览验证)
- POST /api/v1/import/execute (确认导入)

## __init__.py + requirements.txt
- 注册 import 蓝图; 新增 pandas>=1.5.0
This commit is contained in:
yueli
2026-07-16 14:00:28 +08:00
parent 5b1cd4d8d0
commit 5411cabd0a
4 changed files with 541 additions and 0 deletions

View File

@ -258,6 +258,16 @@ def create_app():
except Exception as e:
print(f"❌ 错误: Export 模块注册失败: {e}")
# -----------------------------------------------------
# 2.14 注册批量导入模块 (Import)
# -----------------------------------------------------
try:
from app.api.v1.import_api import import_bp
app.register_blueprint(import_bp, url_prefix='/api/v1/import')
print("✅ Import 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Import 模块导入失败: {e}")
# =========================================================
# 3. 预加载数据模型
# =========================================================

View File

@ -0,0 +1,114 @@
"""
批量导入 API
端点:
GET /api/v1/import/template?type=material|bom → 下载Excel模板
POST /api/v1/import/preview → Dry-Run预览+验证
POST /api/v1/import/execute → 确认导入执行
"""
from flask import Blueprint, request, jsonify, send_file, current_app
from flask_jwt_extended import jwt_required
from app.utils.decorators import permission_required
from app.services.import_service import (
generate_template,
preview_material, preview_bom,
execute_material_import, execute_bom_import,
)
import traceback
import_bp = Blueprint('import_bp', __name__, url_prefix='/api/v1/import')
@import_bp.route('/template', methods=['GET'])
@jwt_required()
@permission_required('material_list')
def download_template():
"""下载导入模板"""
try:
import_type = request.args.get('type', 'material').strip()
if import_type not in ('material', 'bom'):
return jsonify({'code': 400, 'msg': 'type 必须为 material 或 bom'}), 400
file_stream = generate_template(import_type)
filename = f"{'基础信息' if import_type == 'material' else 'BOM表'}_导入模板.xlsx"
return send_file(
file_stream,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
current_app.logger.error(f'下载模板失败: {str(e)}')
return jsonify({'code': 500, 'msg': str(e)}), 500
@import_bp.route('/preview', methods=['POST'])
@jwt_required()
@permission_required('material_list:operation')
def preview_import():
"""Dry-Run 预览 + 验证(不写入数据库)"""
try:
import_type = request.form.get('type', 'material').strip()
file = request.files.get('file')
if not file:
return jsonify({'code': 400, 'msg': '请上传文件'}), 400
if import_type not in ('material', 'bom'):
return jsonify({'code': 400, 'msg': 'type 必须为 material 或 bom'}), 400
file_stream = file.read()
from io import BytesIO
if import_type == 'material':
results = preview_material(BytesIO(file_stream))
else:
results = preview_bom(BytesIO(file_stream))
success_count = sum(1 for r in results if r['status'] == 'success')
error_count = sum(1 for r in results if r['status'] == 'error')
return jsonify({
'code': 200, 'msg': '预览完成',
'data': {
'rows': results,
'total': len(results),
'success_count': success_count,
'error_count': error_count,
}
})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'预览失败: {str(e)}'}), 500
@import_bp.route('/execute', methods=['POST'])
@jwt_required()
@permission_required('material_list:operation')
def execute_import():
"""确认导入执行(事务保护)"""
try:
data = request.get_json() or {}
import_type = data.get('type', 'material').strip()
rows = data.get('rows', [])
if not rows:
return jsonify({'code': 400, 'msg': '导入数据不能为空'}), 400
if import_type not in ('material', 'bom'):
return jsonify({'code': 400, 'msg': 'type 必须为 material 或 bom'}), 400
# 仅导入状态为 success 的行
valid_rows = [r for r in rows if r.get('status') == 'success']
if not valid_rows:
return jsonify({'code': 400, 'msg': '没有可导入的有效数据全部为error状态'}), 400
if import_type == 'material':
result = execute_material_import(valid_rows)
else:
result = execute_bom_import(valid_rows)
code = 200 if result['success'] else 400
return jsonify({'code': code, 'msg': result['msg'], 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'导入失败: {str(e)}'}), 500

View File

@ -0,0 +1,415 @@
"""
Excel/CSV 批量导入服务
支持: 基础信息(MaterialBase) / BOM表(BomTable)
架构: 模板下载 → 预览验证(Dry-Run) → 确认执行
"""
import io
import json
from datetime import datetime
from flask import current_app
from app.extensions import db
from app.models.base import MaterialBase
from app.models.bom import BomTable
# ── 模板定义 ──
MATERIAL_TEMPLATE_HEADERS = [
"所属公司(company_name)", "名称(name)*", "专业名称(common_name)",
"类别(category)*", "类型(material_type)*",
"规格型号(spec_model)*", "计量单位(unit)*",
"参考价格(reference_price)", "强制质检(is_inspection_required)",
"是否启用(is_enabled)"
]
MATERIAL_TEMPLATE_INSTRUCTIONS = [
"请勿修改表头行", "标记*的列为必填",
"类别格式: IRIS/半成品/无人机U (用/分隔层级)",
"强制质检/是否启用: 是/Y/True/1 或 否/N/False/0, 默认否/是",
]
BOM_TEMPLATE_HEADERS = [
"BOM编号(bom_no)*", "版本(version)", "父件名称(parent_name)*",
"父件规格(parent_spec)", "子件名称(child_name)*",
"子件规格(child_spec)", "用量(dosage)*", "损耗率%(loss_rate)",
"备注(remark)", "是否启用(is_enabled)"
]
MATERIAL_EXAMPLE_ROWS = [
["IRIS", "LICA-3000", "激光雷达", "IRIS/成品/LICA", "传感器", "LICA-3000/A", "", 15000, "", ""],
["IRIS", "碳纤维板材", "Carbon Plate", "IRIS/半成品/材料", "原材料", "CF-200/3K", "", 800, "", ""],
["IRIS", "M3内六角螺丝", "", "IRIS/采购件/紧固件", "标准件", "M3x12", "", 0.5, "", ""],
]
BOM_EXAMPLE_ROWS = [
["LICA-3000", "V1.0", "LICA-3000", "LICA-3000/A", "M3内六角螺丝", "M3x12", 8, 0, "固定外壳", ""],
["", "", "", "", "碳纤维板材", "CF-200/3K", 2, 0, "机身框架", ""],
["", "", "", "", "光学镜片", "OG-50", 1, 0, "激光发射", ""],
["UAV-X1", "V1.0", "无人机X1", "UAV-X1/Pro", "LICA-3000", "LICA-3000/A", 1, 0, "", ""],
["", "", "", "", "GPS模块", "GPS-M9N", 1, 0, "导航", ""],
]
BOM_TEMPLATE_INSTRUCTIONS = [
"请勿修改表头行", "标记*的列为必填",
"同一个父件的多个子件,除第一行外,父件相关信息(BOM编号/版本/名称/规格)可留空,系统自动向下填充",
"父件/子件名称必须与系统中已存在的基础信息完全一致",
"版本默认 V1.0; 用量为正整数; 损耗率默认为0",
"同一BOM编号+版本下,父子件组合必须唯一",
]
def parse_boolean(val) -> bool:
"""稳健布尔解析:是/Y/True/1 → True, 否/N/False/0/空 → False"""
if val is None:
return False
s = str(val).strip().lower()
return s in ('', 'y', 'yes', 'true', '1', 't')
def generate_template(import_type: str) -> io.BytesIO:
"""生成 Excel 模板文件"""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
if import_type == 'material':
headers = MATERIAL_TEMPLATE_HEADERS
instructions = MATERIAL_TEMPLATE_INSTRUCTIONS
elif import_type == 'bom':
headers = BOM_TEMPLATE_HEADERS
instructions = BOM_TEMPLATE_INSTRUCTIONS
else:
raise ValueError(f"不支持的导入类型: {import_type}")
wb = Workbook()
ws = wb.active
ws.title = f"{import_type}导入模板"
# 表头样式
header_font = Font(bold=True, size=11)
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font_white = Font(bold=True, size=11, color="FFFFFF")
ws.append(headers)
for cell in ws[1]:
cell.font = header_font_white
cell.fill = header_fill
# 说明行
for instr in instructions:
ws.append([instr] + [''] * (len(headers) - 1))
# 样例数据行(浅绿色背景区分)
example_fill = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
example_rows = MATERIAL_EXAMPLE_ROWS if import_type == 'material' else BOM_EXAMPLE_ROWS
for row_data in example_rows:
ws.append(row_data)
for cell in ws[ws.max_row]:
cell.fill = example_fill
# 例宽
for col in ws.columns:
ws.column_dimensions[col[0].column_letter].width = 22
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output
def preview_material(file_stream) -> list:
"""Dry-Run 预览基础信息导入"""
import pandas as pd
df = pd.read_excel(file_stream, header=0)
# 跳过说明行(第一列包含"请勿"的行)
df = df[~df.iloc[:, 0].astype(str).str.contains('请勿|标记|格式|默认')]
col_map = {
"所属公司(company_name)": "company_name",
"名称(name)*": "name",
"专业名称(common_name)": "common_name",
"类别(category)*": "category",
"类型(material_type)*": "type",
"规格型号(spec_model)*": "spec",
"计量单位(unit)*": "unit",
"参考价格(reference_price)": "reference_price",
"强制质检(is_inspection_required)": "is_inspection_required",
"是否启用(is_enabled)": "is_enabled",
}
df.rename(columns=col_map, inplace=True)
# 批量预查已存在的 name+spec 组合(用于重复检测)
existing = db.session.query(MaterialBase.name, MaterialBase.spec_model).all()
existing_set = {(r.name, r.spec_model or '') for r in existing}
results = []
for idx, row in df.iterrows():
row_num = idx + 2 # Excel 行号
errors = []
name = str(row.get('name', '')).strip() if pd.notna(row.get('name')) else ''
spec = str(row.get('spec', '')).strip() if pd.notna(row.get('spec')) else ''
category = str(row.get('category', '')).strip() if pd.notna(row.get('category')) else ''
mat_type = str(row.get('type', '')).strip() if pd.notna(row.get('type')) else ''
unit = str(row.get('unit', '')).strip() if pd.notna(row.get('unit')) else ''
if not name:
errors.append("名称不能为空")
if not spec:
errors.append("规格型号不能为空")
if not category:
errors.append("类别不能为空")
if not mat_type:
errors.append("类型不能为空")
if not unit:
errors.append("计量单位不能为空")
if name and spec and (name, spec) in existing_set:
errors.append(f"已存在相同名称和规格的数据: {name} / {spec}")
company = str(row.get('company_name', '')).strip() if pd.notna(row.get('company_name')) else ''
common = str(row.get('common_name', '')).strip() if pd.notna(row.get('common_name')) else ''
ref_price = row.get('reference_price')
ref_price = float(ref_price) if pd.notna(ref_price) and str(ref_price).strip() else None
is_enabled = parse_boolean(row.get('is_enabled', True))
is_inspection = parse_boolean(row.get('is_inspection_required', False))
results.append({
'row': row_num,
'status': 'error' if errors else 'success',
'error_msg': '; '.join(errors) if errors else '',
'data': {
'company_name': company,
'name': name,
'common_name': common,
'category': category,
'material_type': mat_type,
'spec_model': spec,
'unit': unit,
'reference_price': ref_price,
'is_inspection_required': is_inspection,
'is_enabled': is_enabled,
}
})
return results
def preview_bom(file_stream) -> list:
"""Dry-Run 预览 BOM 表导入"""
import pandas as pd
df = pd.read_excel(file_stream, header=0)
df = df[~df.iloc[:, 0].astype(str).str.contains('请勿|标记|格式|默认|同一|留空')]
# ★ Forward Fill: 父件上下文列自动向下填充(支持合并单元格 UX
ffill_cols = ["BOM编号(bom_no)*", "版本(version)", "父件名称(parent_name)*", "父件规格(parent_spec)"]
for c in ffill_cols:
if c in df.columns:
df[c] = df[c].ffill()
col_map = {
"BOM编号(bom_no)*": "bom_no",
"版本(version)": "version",
"父件名称(parent_name)*": "parent_name",
"父件规格(parent_spec)": "parent_spec",
"子件名称(child_name)*": "child_name",
"子件规格(child_spec)": "child_spec",
"用量(dosage)*": "dosage",
"损耗率%(loss_rate)": "loss_rate",
"备注(remark)": "remark",
"是否启用(is_enabled)": "is_enabled",
}
df.rename(columns=col_map, inplace=True)
# 批量预查所有 MaterialBase
all_materials = db.session.query(
MaterialBase.id, MaterialBase.name, MaterialBase.spec_model
).all()
mat_lookup = {}
for m in all_materials:
key = (m.name, m.spec_model or '')
mat_lookup[key] = m.id
# 预查所有已存在的 (bom_no, version, parent_id, child_id) 组合
all_bom_pairs = db.session.query(
BomTable.bom_no, BomTable.version,
BomTable.parent_id, BomTable.child_id
).all()
existing_pairs = {(r.bom_no, r.version, r.parent_id, r.child_id) for r in all_bom_pairs}
results = []
for idx, row in df.iterrows():
row_num = idx + 2
errors = []
bom_no = str(row.get('bom_no', '')).strip() if pd.notna(row.get('bom_no')) else ''
version = str(row.get('version', 'V1.0')).strip() if pd.notna(row.get('version')) else 'V1.0'
parent_name = str(row.get('parent_name', '')).strip() if pd.notna(row.get('parent_name')) else ''
parent_spec = str(row.get('parent_spec', '')).strip() if pd.notna(row.get('parent_spec')) else ''
child_name = str(row.get('child_name', '')).strip() if pd.notna(row.get('child_name')) else ''
child_spec = str(row.get('child_spec', '')).strip() if pd.notna(row.get('child_spec')) else ''
dosage = row.get('dosage', 0)
if not bom_no:
errors.append("BOM编号不能为空")
if not parent_name:
errors.append("父件名称不能为空")
if not child_name:
errors.append("子件名称不能为空")
if pd.isna(dosage) or float(dosage) <= 0:
errors.append("用量必须大于0")
parent_key = (parent_name, parent_spec)
parent_id = mat_lookup.get(parent_key) if parent_name else None
if parent_name and parent_id is None:
errors.append(f"父件不存在: {parent_name} / {parent_spec}")
child_key = (child_name, child_spec)
child_id = mat_lookup.get(child_key) if child_name else None
if child_name and child_id is None:
errors.append(f"子件不存在: {child_name} / {child_spec}")
if parent_id and child_id and parent_id == child_id:
errors.append("父件与子件不能是同一物料")
if bom_no and parent_id and child_id:
pair = (bom_no, version, parent_id, child_id)
if pair in existing_pairs:
errors.append(f"该BOM配方已存在: {bom_no} v{version}")
dosage_val = float(dosage) if pd.notna(dosage) else 0
loss_rate = float(row.get('loss_rate', 0)) if pd.notna(row.get('loss_rate')) else 0.0
remark = str(row.get('remark', '')).strip() if pd.notna(row.get('remark')) else ''
is_enabled = parse_boolean(row.get('is_enabled', True))
results.append({
'row': row_num,
'status': 'error' if errors else 'success',
'error_msg': '; '.join(errors) if errors else '',
'data': {
'bom_no': bom_no, 'version': version,
'parent_id': parent_id, 'parent_name': parent_name, 'parent_spec': parent_spec,
'child_id': child_id, 'child_name': child_name, 'child_spec': child_spec,
'dosage': dosage_val, 'loss_rate': loss_rate,
'remark': remark, 'is_enabled': is_enabled,
}
})
return results
def execute_material_import(rows: list) -> dict:
"""执行基础信息批量导入(事务保护)"""
inserted = 0
errors = []
try:
for i, item in enumerate(rows):
d = item.get('data', item)
try:
existing = MaterialBase.query.filter_by(
name=d['name'], spec_model=d['spec_model']
).first()
if existing:
errors.append({'row': i + 2, 'msg': f"已存在: {d['name']} / {d['spec_model']}"})
continue
mat = MaterialBase(
company_name=d.get('company_name', ''),
name=d['name'],
common_name=d.get('common_name', ''),
category=d.get('category', ''),
material_type=d.get('material_type', ''),
spec_model=d.get('spec_model', ''),
unit=d.get('unit', ''),
reference_price=d.get('reference_price'),
is_inspection_required=d.get('is_inspection_required', False),
is_enabled=d.get('is_enabled', True),
)
db.session.add(mat)
inserted += 1
except Exception as e:
errors.append({'row': i + 2, 'msg': str(e)})
if errors:
db.session.rollback()
return {'success': False, 'inserted': 0, 'errors': errors,
'msg': f'导入失败,{len(errors)} 条数据校验未通过'}
db.session.commit()
return {'success': True, 'inserted': inserted, 'errors': [],
'msg': f'成功导入 {inserted} 条基础信息'}
except Exception as e:
db.session.rollback()
return {'success': False, 'inserted': 0,
'errors': [{'row': 0, 'msg': str(e)}], 'msg': f'导入异常: {str(e)}'}
def execute_bom_import(rows: list) -> dict:
"""执行 BOM 批量导入(事务保护)"""
# 预加载所有物料映射
all_materials = db.session.query(
MaterialBase.id, MaterialBase.name, MaterialBase.spec_model
).all()
mat_lookup = {}
for m in all_materials:
mat_lookup[(m.name, m.spec_model or '')] = m.id
inserted = 0
errors = []
try:
for i, item in enumerate(rows):
d = item.get('data', item)
try:
parent_id = d.get('parent_id')
child_id = d.get('child_id')
bom_no = d.get('bom_no', '')
version = d.get('version', 'V1.0')
if not parent_id:
pk = (d.get('parent_name', ''), d.get('parent_spec', ''))
parent_id = mat_lookup.get(pk)
if not child_id:
ck = (d.get('child_name', ''), d.get('child_spec', ''))
child_id = mat_lookup.get(ck)
if not parent_id or not child_id:
errors.append({'row': i + 2, 'msg': '父件或子件在系统中不存在'})
continue
existing = BomTable.query.filter_by(
bom_no=bom_no, version=version,
parent_id=parent_id, child_id=child_id
).first()
if existing:
errors.append({'row': i + 2, 'msg': f'BOM配方已存在: {bom_no} v{version}'})
continue
bom = BomTable(
bom_no=bom_no, version=version,
parent_id=parent_id, child_id=child_id,
dosage=d.get('dosage', 0),
loss_rate=d.get('loss_rate', 0),
remark=d.get('remark', ''),
is_enabled=d.get('is_enabled', True),
)
db.session.add(bom)
inserted += 1
except Exception as e:
errors.append({'row': i + 2, 'msg': str(e)})
if errors:
db.session.rollback()
return {'success': False, 'inserted': 0, 'errors': errors,
'msg': f'导入失败,{len(errors)} 条数据校验未通过'}
db.session.commit()
return {'success': True, 'inserted': inserted, 'errors': [],
'msg': f'成功导入 {inserted} 条BOM配方'}
except Exception as e:
db.session.rollback()
return {'success': False, 'inserted': 0,
'errors': [{'row': 0, 'msg': str(e)}], 'msg': f'导入异常: {str(e)}'}

View File

@ -24,6 +24,8 @@ qrcode[pil]>=7.4.2
Flask-JWT-Extended==4.6.0
# [新增] Excel 处理库 (解决 No module named 'openpyxl' 报错)
openpyxl>=3.1.2
# [新增] 数据导入解析库 (Excel/CSV 批量导入)
pandas>=1.5.0
# [新增] 定时任务调度器 (库存预警每日邮件)
APScheduler==3.10.4
# [新增] 时区处理 (APScheduler 需要)