From e3c626aba28e8def2f633bf5f4adbdb3171f3cac Mon Sep 17 00:00:00 2001 From: shijing Date: Thu, 19 Mar 2026 11:14:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E6=9D=90=E6=96=99=E8=A1=A8=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/material/importers.py | 181 ++++++++++++++++++ backend/apps/material/views.py | 23 +++ frontend/public/material_import_template.xlsx | Bin 0 -> 26156 bytes frontend/src/api/material.js | 9 + frontend/src/views/MaterialManage.vue | 34 +++- 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 backend/apps/material/importers.py create mode 100644 frontend/public/material_import_template.xlsx 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 0000000000000000000000000000000000000000..44d4c04877d9002d9494d5e9b6d34aece56f0c94 GIT binary patch literal 26156 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 literal 0 HcmV?d00001 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(() => {