diff --git a/backend/apps/material/importers.py b/backend/apps/material/importers.py new file mode 100644 index 0000000..bb1315a --- /dev/null +++ b/backend/apps/material/importers.py @@ -0,0 +1,181 @@ +import re +from typing import Any, Dict, List, Optional, Tuple + +import openpyxl + +from apps.factory.models import Factory +from apps.material.models import Material + + +MAJOR_CATEGORY_MAP = { + "建筑": "architecture", + "景观": "landscape", + "设备": "equipment", + "装修": "decoration", + "室内": "decoration", +} + +STAGE_VALUES = {choice[0] for choice in Material.STAGE_CHOICES} +IMPORTANCE_LEVEL_VALUES = {choice[0] for choice in Material.IMPORTANCE_LEVEL_CHOICES} +UNIT_SPLIT_RE = re.compile(r"[\s,,、/;;]+") + + +def _cell(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _single_line(value: Any, max_len: int = 255) -> str: + text = _cell(value) + if not text: + return "" + text = re.sub(r"\s+", " ", text) + return text[:max_len].strip() + + +def _parse_choice(value: Any, allowed_values: set) -> Optional[str]: + text = _cell(value) + if not text: + return None + return text if text in allowed_values else None + + +def _resolve_factory(unit_name: str, factory_cache: Dict[str, Optional[Factory]], unrecognized_factory: Factory) -> Tuple[Factory, bool, bool]: + if not unit_name: + return unrecognized_factory, True, False + + if unit_name not in factory_cache: + candidates = [part.strip() for part in UNIT_SPLIT_RE.split(unit_name) if part.strip()] + search_terms = candidates or [unit_name] + + matched_factory = None + for term in search_terms: + matched_factory = Factory.objects.filter(brand=term).first() + if matched_factory: + break + matched_factory = Factory.objects.filter(brand__icontains=term).first() + if matched_factory: + break + term_lower = term.lower() + for factory in Factory.objects.all(): + brand = (factory.brand or "").lower() + if brand and brand in term_lower: + matched_factory = factory + break + if matched_factory: + break + + factory_cache[unit_name] = matched_factory + + factory = factory_cache[unit_name] + if factory: + return factory, False, False + + created_factory = Factory.objects.create( + factory_name=unit_name, + brand=unit_name, + province="北京", + city="北京", + district="北京", + ) + factory_cache[unit_name] = created_factory + return created_factory, False, True + + +def import_materials_plan_excel(file_obj) -> Dict[str, int]: + workbook = openpyxl.load_workbook(file_obj, read_only=True, data_only=True) + worksheet = workbook[workbook.sheetnames[0]] + rows = list(worksheet.iter_rows(values_only=True)) + workbook.close() + + if len(rows) < 3: + raise ValueError("Excel 内容不足,未找到表头或数据。") + + header = [_cell(value) for value in rows[1]] + header_index = {name: idx for idx, name in enumerate(header) if name} + + required_headers = ["材料大类", "细分种类", "材料名称", "材料单位名称"] + missing_headers = [name for name in required_headers if name not in header_index] + if missing_headers: + raise ValueError(f"缺少必要表头: {', '.join(missing_headers)}") + + def get(row: Tuple[Any, ...], key: str) -> str: + idx = header_index.get(key, -1) + if idx < 0 or idx >= len(row): + return "" + return _cell(row[idx]) + + unrecognized_factory, _ = Factory.objects.get_or_create( + brand="未识别的品牌", + defaults={ + "factory_name": "未识别的品牌工厂", + "province": "-", + "city": "-", + }, + ) + + created = 0 + updated = 0 + skipped = 0 + unresolved_factory = 0 + created_factory = 0 + factory_cache: Dict[str, Optional[Factory]] = {} + current_major_category = "" + + for row in rows[2:]: + if not row: + continue + + row_values = [_cell(value) for value in row] + if not any(row_values): + continue + + major_raw = get(row, "材料大类") + if major_raw: + current_major_category = major_raw + + material_name = _single_line(get(row, "材料名称")) + material_category = _single_line(get(row, "细分种类")) + if not material_name or not material_category or not current_major_category: + skipped += 1 + continue + + unit_name = get(row, "材料单位名称") + factory, is_unresolved, is_created_factory = _resolve_factory(unit_name, factory_cache, unrecognized_factory) + if is_unresolved: + unresolved_factory += 1 + if is_created_factory: + created_factory += 1 + + defaults = { + "stage": _parse_choice(get(row, "阶段"), STAGE_VALUES), + "importance_level": _parse_choice(get(row, "重要等级"), IMPORTANCE_LEVEL_VALUES), + "landing_project": _single_line(get(row, "落地项目")) or None, + "contact_person": _single_line(get(row, "对接人")) or None, + "contact_phone": _single_line(get(row, "对接人联系方式")) or None, + "handler": _single_line(get(row, "经办人")) or None, + "remark": _single_line(get(row, "备注")) or None, + "factory": factory, + } + + material, created_flag = Material.objects.update_or_create( + name=material_name, + major_category=MAJOR_CATEGORY_MAP.get(current_major_category, "architecture"), + material_category=material_category, + factory=factory, + defaults=defaults, + ) + + if created_flag: + created += 1 + else: + updated += 1 + + return { + "created": created, + "updated": updated, + "skipped": skipped, + "unresolved_factory": unresolved_factory, + "created_factory": created_factory, + } diff --git a/backend/apps/material/views.py b/backend/apps/material/views.py index fc60004..163c3c9 100644 --- a/backend/apps/material/views.py +++ b/backend/apps/material/views.py @@ -4,8 +4,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from rest_framework.exceptions import PermissionDenied +from rest_framework.parsers import MultiPartParser from .models import Material, MaterialCategory, MaterialSubcategory from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer +from .importers import import_materials_plan_excel class MaterialViewSet(ModelViewSet): @@ -199,6 +201,27 @@ class MaterialViewSet(ModelViewSet): 'status': Material.STATUS_CHOICES, }) + @action(detail=False, methods=['post'], parser_classes=[MultiPartParser], url_path='import-excel') + def import_excel(self, request): + if request.user.role != 'admin': + raise PermissionDenied("只有管理员可以导入材料") + + excel_file = request.FILES.get('file') + if not excel_file: + return Response({"detail": "未提供 Excel 文件"}, status=status.HTTP_400_BAD_REQUEST) + + if not excel_file.name.lower().endswith('.xlsx'): + return Response({"detail": "仅支持 .xlsx 格式的 Excel 文件"}, status=status.HTTP_400_BAD_REQUEST) + + try: + result = import_materials_plan_excel(excel_file) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: + return Response({"detail": f"导入失败: {exc}"}, status=status.HTTP_400_BAD_REQUEST) + + return Response(result) + class MaterialCategoryViewSet(ModelViewSet): """ diff --git a/frontend/public/material_import_template.xlsx b/frontend/public/material_import_template.xlsx new file mode 100644 index 0000000..44d4c04 Binary files /dev/null and b/frontend/public/material_import_template.xlsx differ diff --git a/frontend/src/api/material.js b/frontend/src/api/material.js index 03f8502..d6fe02e 100644 --- a/frontend/src/api/material.js +++ b/frontend/src/api/material.js @@ -29,6 +29,15 @@ export const uploadImage = async (file) => { return data } +export const importMaterialsExcel = async (file) => { + const formData = new FormData() + formData.append('file', file) + const { data } = await api.post('/material/import-excel/', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data +} + export const deleteMaterial = async (id) => { const { data } = await api.delete(`/material/${id}/`) return data diff --git a/frontend/src/views/MaterialManage.vue b/frontend/src/views/MaterialManage.vue index 414b4e1..be41aaf 100644 --- a/frontend/src/views/MaterialManage.vue +++ b/frontend/src/views/MaterialManage.vue @@ -10,7 +10,20 @@ 查询 + + {{ importing ? '导入中...' : '导入数据' }} + 新增材料 +
+ + 模板 +
@@ -201,7 +214,7 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { useAuth } from '@/store/auth' -import { fetchMaterials, fetchMaterialDetail, createMaterial, updateMaterial, deleteMaterial, submitMaterial, approveMaterial, rejectMaterial, fetchMaterialChoices, uploadImage } from '@/api/material' +import { fetchMaterials, fetchMaterialDetail, createMaterial, updateMaterial, deleteMaterial, submitMaterial, approveMaterial, rejectMaterial, fetchMaterialChoices, uploadImage, importMaterialsExcel } from '@/api/material' import { fetchCategories, fetchSubcategories } from '@/api/category' import { fetchFactorySimple } from '@/api/factory' @@ -220,6 +233,7 @@ const dialogTitle = ref('') const isEdit = ref(false) const currentId = ref(null) const uploading = ref(false) +const importing = ref(false) const filters = reactive({ name: '', @@ -437,6 +451,20 @@ const onSave = async () => { } } +const handleImportExcel = async (options) => { + importing.value = true + try { + const result = await importMaterialsExcel(options.file) + pagination.page = 1 + await loadMaterials() + ElMessage.success(`导入完成:新增 ${result.created} 条,更新 ${result.updated} 条,跳过 ${result.skipped} 条,新建工厂 ${result.created_factory || 0} 条`) + } catch (error) { + ElMessage.error(error.response?.data?.detail || '导入失败') + } finally { + importing.value = false + } +} + const onDelete = (row) => { ElMessageBox.confirm(`确认删除材料 ${row.name} 吗?`, '提示', { type: 'warning' }) .then(async () => { @@ -495,6 +523,10 @@ onMounted(() => {