From c1758ca6495d793697dc7a127d46036011b2f39d Mon Sep 17 00:00:00 2001 From: shijing Date: Mon, 23 Mar 2026 13:52:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:3.20=E6=97=A5=E6=9D=90=E6=96=99=E5=8F=AF?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=EF=BC=8C=E5=AF=BC=E5=85=A5=E5=90=8E=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E7=B3=BB=E7=BB=9F=E5=AD=98=E5=9C=A8=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=EF=BC=8C=E7=84=B6=E5=90=8E=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=8E=E5=86=8D=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/material/importers.py | 317 +++++++++++++++++++++----- backend/apps/material/models.py | 6 +- backend/apps/material/views.py | 208 +++++++++++++++++ backend/tmp_validation_test.xlsx | Bin 0 -> 5048 bytes backend/tmp_validation_test_2.xlsx | Bin 0 -> 5180 bytes backend/tmp_validation_test_3.xlsx | Bin 0 -> 5460 bytes backend/tmp_validation_test_4.xlsx | Bin 0 -> 5365 bytes frontend/src/api/material.js | 8 + frontend/src/views/MaterialManage.vue | 65 +++++- 9 files changed, 533 insertions(+), 71 deletions(-) create mode 100644 backend/tmp_validation_test.xlsx create mode 100644 backend/tmp_validation_test_2.xlsx create mode 100644 backend/tmp_validation_test_3.xlsx create mode 100644 backend/tmp_validation_test_4.xlsx diff --git a/backend/apps/material/importers.py b/backend/apps/material/importers.py index d8fe82a..a8b8afb 100644 --- a/backend/apps/material/importers.py +++ b/backend/apps/material/importers.py @@ -1,4 +1,5 @@ import re +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Any, Dict, List, Optional, Tuple import openpyxl @@ -16,10 +17,62 @@ MAJOR_CATEGORY_MAP = { "室内": "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,,、/;;]+") +MULTI_VALUE_SPLIT_RE = re.compile(r"[\n,,、/;;]+") INITIAL_PASSWORD = "abc!0000" +HEADER_ALIASES = { + "major_category": ("材料大类", "专业类别"), + "material_category": ("细分种类", "材料分类"), + "material_subcategory": ("材料子类", "材料子分类"), + "material_name": ("材料名称",), + "unit_name": ("材料单位名称", "所属工厂", "品牌"), + "factory_name": ("工厂全称", "生产工厂全称"), + "stage": ("阶段",), + "importance_level": ("重要等级",), + "landing_project": ("落地项目",), + "contact_person": ("对接人",), + "contact_phone": ("对接人联系方式", "联系方式", "联系电话"), + "handler": ("经办人",), + "remark": ("备注",), + "spec": ("规格型号",), + "standard": ("符合标准",), + "application_scene": ("应用场景",), + "application_desc": ("应用说明", "应用场景说明"), + "replace_type": ("替代材料类型",), + "advantage": ("竞争优势",), + "advantage_desc": ("优势说明",), + "cost_compare": ("成本对比(%)", "成本对比"), + "cost_desc": ("成本说明",), + "cases": ("案例",), + "quality_level": ("质量等级",), + "durability_level": ("耐久等级",), + "eco_level": ("环保等级",), + "carbon_level": ("低碳等级",), + "score_level": ("总评分",), + "connection_method": ("连接方式",), + "construction_method": ("施工工艺",), + "limit_condition": ("限制条件",), + "status": ("状态",), +} +MAJOR_CATEGORY_VALUE_MAP = {str(value): value for value, _ in Material.MAJOR_CATEGORY_CHOICES} +MAJOR_CATEGORY_VALUE_MAP.update({label: value for value, label in Material.MAJOR_CATEGORY_CHOICES}) +MAJOR_CATEGORY_VALUE_MAP["室内"] = "decoration" +STAGE_VALUE_MAP = {str(value): value for value, _ in Material.STAGE_CHOICES} +STAGE_VALUE_MAP.update({label: value for value, label in Material.STAGE_CHOICES}) +IMPORTANCE_LEVEL_VALUE_MAP = {str(value): value for value, _ in Material.IMPORTANCE_LEVEL_CHOICES} +IMPORTANCE_LEVEL_VALUE_MAP.update({label: value for value, label in Material.IMPORTANCE_LEVEL_CHOICES}) +REPLACE_TYPE_VALUE_MAP = {str(value): value for value, _ in Material.REPLACE_TYPE_CHOICES} +REPLACE_TYPE_VALUE_MAP.update({label: value for value, label in Material.REPLACE_TYPE_CHOICES}) +APPLICATION_SCENE_VALUE_MAP = {str(value): value for value, _ in Material.APPLICATION_SCENE_CHOICES} +APPLICATION_SCENE_VALUE_MAP.update({label: value for value, label in Material.APPLICATION_SCENE_CHOICES}) +ADVANTAGE_VALUE_MAP = {str(value): value for value, _ in Material.ADVANTAGE_CHOICES} +ADVANTAGE_VALUE_MAP.update({label: value for value, label in Material.ADVANTAGE_CHOICES}) +STAR_LEVEL_VALUE_MAP = {str(value): value for value, _ in Material.STAR_LEVEL_CHOICES} +STAR_LEVEL_VALUE_MAP.update({label: value for value, label in Material.STAR_LEVEL_CHOICES}) +STATUS_VALUE_MAP = {str(value): value for value, _ in Material.STATUS_CHOICES} +STATUS_VALUE_MAP.update({label: value for value, label in Material.STATUS_CHOICES}) +DECIMAL_2_PLACES = Decimal("0.01") +MAX_COST_COMPARE = Decimal("999.99") def _cell(value: Any) -> str: @@ -28,6 +81,10 @@ def _cell(value: Any) -> str: return str(value).strip() +def _normalize_header(value: Any) -> str: + return re.sub(r"\s+", "", _cell(value)) + + def _single_line(value: Any, max_len: int = 255) -> str: text = _cell(value) if not text: @@ -36,11 +93,70 @@ def _single_line(value: Any, max_len: int = 255) -> str: return text[:max_len].strip() -def _parse_choice(value: Any, allowed_values: set) -> Optional[str]: +def _parse_mapped_choice(value: Any, mapping: Dict[str, Any]) -> Optional[Any]: text = _cell(value) if not text: return None - return text if text in allowed_values else None + return mapping.get(text) + + +def _parse_multi_choice(value: Any, mapping: Dict[str, Any]) -> List[Any]: + text = _cell(value) + if not text: + return [] + + parsed: List[Any] = [] + seen = set() + for item in MULTI_VALUE_SPLIT_RE.split(text): + normalized = item.strip() + if not normalized: + continue + mapped = mapping.get(normalized) + if mapped is None or mapped in seen: + continue + parsed.append(mapped) + seen.add(mapped) + return parsed + + +def _parse_decimal(value: Any) -> Optional[Decimal]: + text = _cell(value) + if not text: + return None + normalized = text.replace("%", "").replace("%", "").replace(",", "").strip() + try: + parsed = Decimal(normalized).quantize(DECIMAL_2_PLACES, rounding=ROUND_HALF_UP) + except (InvalidOperation, ValueError): + return None + if parsed.copy_abs() > MAX_COST_COMPARE: + return None + return parsed + + +def _optional_text(value: Any) -> Optional[str]: + text = _cell(value) + return text or None + + +def _optional_single_line(value: Any, max_len: int = 255) -> Optional[str]: + text = _single_line(value, max_len=max_len) + return text or None + + +def _find_header_row(rows: List[Tuple[Any, ...]]) -> Tuple[int, Dict[str, int]]: + required_fields = ("major_category", "material_category", "material_name", "unit_name") + + for row_index, row in enumerate(rows[:10]): + header_index = { + _normalize_header(name): idx + for idx, name in enumerate(row) + if _normalize_header(name) + } + if all(any(_normalize_header(alias) in header_index for alias in HEADER_ALIASES[field]) for field in required_fields): + return row_index, header_index + + preferred_headers = [HEADER_ALIASES[field][0] for field in required_fields] + raise ValueError(f"缺少必要表头: {', '.join(preferred_headers)}") def _unique_username(user_model, base: str) -> str: @@ -70,46 +186,74 @@ def _ensure_factory_user(factory: Factory, unit_name: str) -> bool: return True -def _resolve_factory(unit_name: str, factory_cache: Dict[str, Optional[Factory]], unrecognized_factory: Factory) -> Tuple[Factory, bool, bool]: - if not unit_name: +def _resolve_factory( + unit_name: str, + factory_name: str, + factory_cache: Dict[Tuple[str, str], Optional[Factory]], + unrecognized_factory: Factory, +) -> Tuple[Factory, bool, bool]: + if not unit_name and not factory_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] + cache_key = (unit_name, factory_name) + + if cache_key not in factory_cache: + search_terms: List[str] = [] + for source in (unit_name, factory_name): + if not source: + continue + search_terms.append(source) + search_terms.extend(part.strip() for part in UNIT_SPLIT_RE.split(source) if part.strip()) + + deduped_terms: List[str] = [] + seen_terms = set() + for term in search_terms: + if term not in seen_terms: + deduped_terms.append(term) + seen_terms.add(term) matched_factory = None - for term in search_terms: + all_factories = list(Factory.objects.all()) + for term in deduped_terms: matched_factory = Factory.objects.filter(brand=term).first() + if matched_factory: + break + matched_factory = Factory.objects.filter(factory_name=term).first() if matched_factory: break matched_factory = Factory.objects.filter(brand__icontains=term).first() + if matched_factory: + break + matched_factory = Factory.objects.filter(factory_name__icontains=term).first() if matched_factory: break term_lower = term.lower() - for factory in Factory.objects.all(): + for factory in all_factories: brand = (factory.brand or "").lower() - if brand and brand in term_lower: + full_name = (factory.factory_name or "").lower() + if (brand and brand in term_lower) or (full_name and full_name in term_lower): matched_factory = factory break if matched_factory: break - factory_cache[unit_name] = matched_factory + factory_cache[cache_key] = matched_factory - factory = factory_cache[unit_name] + factory = factory_cache[cache_key] if factory: return factory, False, False + brand = _single_line(unit_name or factory_name, max_len=100) + full_name = _single_line(factory_name or unit_name, max_len=255) or brand created_factory = Factory.objects.create( - factory_name=unit_name, - brand=unit_name, + factory_name=full_name, + brand=brand, province="北京", city="北京", district="北京", ) - _ensure_factory_user(created_factory, unit_name) - factory_cache[unit_name] = created_factory + _ensure_factory_user(created_factory, brand or full_name) + factory_cache[cache_key] = created_factory return created_factory, False, True @@ -119,22 +263,20 @@ def import_materials_plan_excel(file_obj) -> Dict[str, int]: rows = list(worksheet.iter_rows(values_only=True)) workbook.close() - if len(rows) < 3: + if len(rows) < 2: raise ValueError("Excel 内容不足,未找到表头或数据。") - header = [_cell(value) for value in rows[1]] - header_index = {name: idx for idx, name in enumerate(header) if name} + header_row_index, header_index = _find_header_row(rows) + has_status_column = any(_normalize_header(alias) in header_index for alias in HEADER_ALIASES["status"]) - 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]) + def get(row: Tuple[Any, ...], field: str) -> str: + for alias in HEADER_ALIASES.get(field, ()): + idx = header_index.get(_normalize_header(alias), -1) + if 0 <= idx < len(row): + value = _cell(row[idx]) + if value: + return value + return "" unrecognized_factory, _ = Factory.objects.get_or_create( brand="未识别的品牌", @@ -151,10 +293,10 @@ def import_materials_plan_excel(file_obj) -> Dict[str, int]: unresolved_factory = 0 created_factory = 0 created_user = 0 - factory_cache: Dict[str, Optional[Factory]] = {} + factory_cache: Dict[Tuple[str, str], Optional[Factory]] = {} current_major_category = "" - for row in rows[2:]: + for row in rows[header_row_index + 1:]: if not row: continue @@ -162,48 +304,107 @@ def import_materials_plan_excel(file_obj) -> Dict[str, int]: if not any(row_values): continue - major_raw = get(row, "材料大类") + major_raw = get(row, "major_category") if major_raw: current_major_category = major_raw - material_name = _single_line(get(row, "材料名称")) - material_category = _single_line(get(row, "细分种类")) + material_name = _single_line(get(row, "material_name")) + material_category = _single_line(get(row, "material_category")) 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) + major_category_value = MAJOR_CATEGORY_VALUE_MAP.get( + current_major_category, + MAJOR_CATEGORY_MAP.get(current_major_category, "architecture"), + ) + + unit_name = get(row, "unit_name") + factory_name = get(row, "factory_name") + factory, is_unresolved, is_created_factory = _resolve_factory(unit_name, factory_name, factory_cache, unrecognized_factory) if is_unresolved: unresolved_factory += 1 if is_created_factory: created_factory += 1 created_user += 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, - "status": "approved", - } + defaults = {"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, - ) + material_subcategory = _optional_single_line(get(row, "material_subcategory")) + if material_subcategory is not None: + defaults["material_subcategory"] = material_subcategory - if created_flag: - created += 1 + stage = _parse_mapped_choice(get(row, "stage"), STAGE_VALUE_MAP) + if stage is not None: + defaults["stage"] = stage + + importance_level = _parse_mapped_choice(get(row, "importance_level"), IMPORTANCE_LEVEL_VALUE_MAP) + if importance_level is not None: + defaults["importance_level"] = importance_level + + for field_name in ("landing_project", "contact_person", "contact_phone", "handler", "remark", "spec", "standard", "connection_method", "construction_method"): + value = _optional_single_line(get(row, field_name)) + if value is not None: + defaults[field_name] = value + + for field_name in ("application_desc", "advantage_desc", "cost_desc", "cases", "limit_condition"): + value = _optional_text(get(row, field_name)) + if value is not None: + defaults[field_name] = value + + application_scene = _parse_multi_choice(get(row, "application_scene"), APPLICATION_SCENE_VALUE_MAP) + if application_scene: + defaults["application_scene"] = application_scene + + replace_type = _parse_mapped_choice(get(row, "replace_type"), REPLACE_TYPE_VALUE_MAP) + if replace_type is not None: + defaults["replace_type"] = replace_type + + advantage = _parse_multi_choice(get(row, "advantage"), ADVANTAGE_VALUE_MAP) + if advantage: + defaults["advantage"] = advantage + + cost_compare = _parse_decimal(get(row, "cost_compare")) + if cost_compare is not None: + defaults["cost_compare"] = cost_compare + + for source_field, target_field in ( + ("quality_level", "quality_level"), + ("durability_level", "durability_level"), + ("eco_level", "eco_level"), + ("carbon_level", "carbon_level"), + ("score_level", "score_level"), + ): + star_level = _parse_mapped_choice(get(row, source_field), STAR_LEVEL_VALUE_MAP) + if star_level is not None: + defaults[target_field] = star_level + + if has_status_column: + status_value = _parse_mapped_choice(get(row, "status"), STATUS_VALUE_MAP) + if status_value is not None: + defaults["status"] = status_value else: + defaults["status"] = "approved" + + existing_material = Material.objects.filter( + name=material_name, + major_category=major_category_value, + material_category=material_category, + ).order_by("-updated_at", "-id").first() + + if existing_material: + for field_name, value in defaults.items(): + setattr(existing_material, field_name, value) + existing_material.save() updated += 1 + else: + Material.objects.create( + name=material_name, + major_category=major_category_value, + material_category=material_category, + **defaults, + ) + created += 1 return { "created": created, diff --git a/backend/apps/material/models.py b/backend/apps/material/models.py index b3b7d7d..cd85d18 100644 --- a/backend/apps/material/models.py +++ b/backend/apps/material/models.py @@ -57,8 +57,8 @@ class Material(models.Model): ) name = models.CharField(max_length=255, verbose_name='材料名称') - major_category = models.CharField(max_length=20, choices=MAJOR_CATEGORY_CHOICES, verbose_name='专业类别') - material_category = models.CharField(max_length=255, verbose_name='材料分类') + major_category = models.CharField(max_length=20, choices=MAJOR_CATEGORY_CHOICES, verbose_name='材料大类') + material_category = models.CharField(max_length=255, verbose_name='细分种类') material_subcategory = models.CharField(max_length=255, blank=True, null=True, verbose_name='材料子分类') stage = models.CharField(max_length=20, choices=STAGE_CHOICES, blank=True, null=True, verbose_name='阶段') importance_level = models.CharField(max_length=20, choices=IMPORTANCE_LEVEL_CHOICES, blank=True, null=True, verbose_name='重要等级') @@ -86,7 +86,7 @@ class Material(models.Model): connection_method = models.CharField(max_length=255, blank=True, null=True, verbose_name='连接方式') construction_method = models.CharField(max_length=255, blank=True, null=True, verbose_name='施工工艺') limit_condition = models.TextField(blank=True, null=True, verbose_name='限制条件') - factory = models.ForeignKey('factory.Factory', on_delete=models.CASCADE, related_name='materials', verbose_name='所属工厂') + factory = models.ForeignKey('factory.Factory', on_delete=models.CASCADE, related_name='materials', verbose_name='材料单位名称') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name='状态') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') diff --git a/backend/apps/material/views.py b/backend/apps/material/views.py index 163c3c9..f332729 100644 --- a/backend/apps/material/views.py +++ b/backend/apps/material/views.py @@ -1,3 +1,12 @@ +from io import BytesIO +from pathlib import Path + +from django.http import FileResponse, Http404 +from django.utils import timezone +from openpyxl import Workbook +from openpyxl.worksheet.datavalidation import DataValidation +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter from rest_framework import generics, status from rest_framework.decorators import api_view, action from rest_framework.permissions import IsAuthenticated @@ -10,6 +19,126 @@ from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCat from .importers import import_materials_plan_excel +def _join_choice_values(values, choices): + if not values: + return "" + mapping = dict(choices) + if isinstance(values, str): + values = [values] + return "、".join(mapping.get(value, value) for value in values) + + +def _apply_template_header_style(worksheet, row_index, total_columns): + header_font = Font(name='SimSun', size=18, bold=True, color='FFFFFFFF') + header_fill = PatternFill(fill_type='solid', fgColor='FFC39E61') + header_alignment = Alignment(horizontal='center', vertical='center') + header_side = Side(style='thin', color='FF000000') + header_border = Border(left=header_side, right=header_side, top=header_side) + + worksheet.row_dimensions[row_index].height = 23.25 + for column_index in range(1, total_columns + 1): + cell = worksheet.cell(row=row_index, column=column_index) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = header_border + + +def _apply_template_title_style(worksheet, row_index, total_columns, title): + title_font = Font(name='SimSun', size=18, bold=True, color='FFFFFFFF') + title_fill = PatternFill(fill_type='solid', fgColor='FF039843') + title_alignment = Alignment(horizontal='center', vertical='center') + + worksheet.merge_cells(start_row=row_index, start_column=1, end_row=row_index, end_column=total_columns) + cell = worksheet.cell(row=row_index, column=1) + cell.value = title + cell.font = title_font + cell.fill = title_fill + cell.alignment = title_alignment + worksheet.row_dimensions[row_index].height = 23.25 + + +def _text_display_width(text): + width = 0 + for char in str(text or ""): + width += 2 if ord(char) > 127 else 1 + return width + + +def _apply_header_column_widths(worksheet, headers): + for column_index, header in enumerate(headers, start=1): + display_width = _text_display_width(header) + worksheet.column_dimensions[get_column_letter(column_index)].width = max(16, display_width + 4) + + +def _apply_export_dropdowns(worksheet, headers, data_row_count): + dropdown_configs = [ + { + "headers": {"质量等级", "耐久等级", "环保等级", "低碳等级", "总评分"}, + "options": ["1星", "2星", "3星"], + "prompt": "请选择星级", + "error": "仅支持 1星、2星、3星", + }, + { + "headers": {"材料子类"}, + "options": [ + "节能材料", + "涂料", + "界面剂", + "地坪", + "水磨石", + "S垫疏水垫", + "商用地垫", + "方块毯", + "保温板(无机材料)", + "预制墙板(无机材料)", + ], + "prompt": "请选择材料子类", + "error": "仅支持下拉列表中的材料子类", + }, + { + "headers": {"替代材料类型"}, + "options": ["平替", "新研发"], + "prompt": "请选择替代材料类型", + "error": "仅支持 平替、新研发", + }, + { + "headers": {"竞争优势"}, + "options": ["品质", "成本"], + "prompt": "可从下拉选择;如需多填,请手动输入并用、分隔", + "error": "仅支持 品质、成本;多值请用、分隔", + }, + { + "headers": {"应用场景"}, + "options": ["府系", "境系", "城系", "住系", "保障房"], + "prompt": "Excel 单元格下拉不支持原生多选;如需多填,请手动输入并用、分隔", + "error": "仅支持 府系、境系、城系、住系、保障房;多值请用、分隔", + }, + ] + + start_row = 3 + end_row = max(start_row, data_row_count + 202) + source_column_index = len(headers) + 2 + + for config in dropdown_configs: + source_column_letter = get_column_letter(source_column_index) + for row_index, option in enumerate(config["options"], start=1): + worksheet.cell(row=row_index, column=source_column_index, value=option) + worksheet.column_dimensions[source_column_letter].hidden = True + option_formula = f"=${source_column_letter}$1:${source_column_letter}${len(config['options'])}" + + for column_index, header in enumerate(headers, start=1): + if header not in config["headers"]: + continue + validation = DataValidation(type="list", formula1=option_formula, allow_blank=True) + validation.prompt = config["prompt"] + validation.error = config["error"] + worksheet.add_data_validation(validation) + validation.add(f"{get_column_letter(column_index)}{start_row}:{get_column_letter(column_index)}{end_row}") + + source_column_index += 1 + + class MaterialViewSet(ModelViewSet): """ 材料视图集 @@ -201,6 +330,85 @@ class MaterialViewSet(ModelViewSet): 'status': Material.STATUS_CHOICES, }) + @action(detail=False, methods=['get'], url_path='template') + def template(self, request): + template_path = Path(__file__).resolve().parents[3] / 'frontend' / 'public' / 'material_import_template.xlsx' + if not template_path.exists(): + raise Http404("模板文件不存在") + + return FileResponse( + template_path.open('rb'), + as_attachment=True, + filename='material_import_template.xlsx', + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + + @action(detail=False, methods=['get'], url_path='export-excel') + def export_excel(self, request): + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "材料" + headers = [ + "材料名称","材料大类", "细分种类", "材料子类", "阶段", "重要等级", + "落地项目", "对接人", "对接人联系方式", "经办人", "材料单位名称", "工厂全称", "规格型号", + "符合标准", "应用场景", "应用说明", "替代材料类型", "竞争优势", "优势说明", + "成本对比(%)", "成本说明", "案例", "质量等级", "耐久等级", "环保等级", + "低碳等级", "总评分", "连接方式", "施工工艺", "限制条件", "备注", + ] + _apply_template_title_style(worksheet, 1, len(headers), "新材料工作进展目录") + worksheet.append(headers) + _apply_template_header_style(worksheet, 2, len(headers)) + _apply_header_column_widths(worksheet, headers) + + queryset = self.get_queryset().select_related('factory') + for material in queryset: + worksheet.append([ + material.name or "", + material.get_major_category_display() or "", + material.material_category or "", + material.material_subcategory or "", + material.get_stage_display() or "", + material.get_importance_level_display() or "", + material.landing_project or "", + material.contact_person or "", + material.contact_phone or "", + material.handler or "", + material.factory.brand if material.factory else "", + material.factory.factory_name if material.factory else "", + material.spec or "", + material.standard or "", + _join_choice_values(material.application_scene, Material.APPLICATION_SCENE_CHOICES), + material.application_desc or "", + material.get_replace_type_display() or "", + _join_choice_values(material.advantage, Material.ADVANTAGE_CHOICES), + material.advantage_desc or "", + "" if material.cost_compare is None else str(material.cost_compare), + material.cost_desc or "", + material.cases or "", + material.get_quality_level_display() or "", + material.get_durability_level_display() or "", + material.get_eco_level_display() or "", + material.get_carbon_level_display() or "", + material.get_score_level_display() or "", + material.connection_method or "", + material.construction_method or "", + material.limit_condition or "", + material.remark or "", + ]) + + _apply_export_dropdowns(worksheet, headers, queryset.count()) + + output = BytesIO() + workbook.save(output) + output.seek(0) + filename = f"materials_export_{timezone.localtime().strftime('%Y%m%d_%H%M%S')}.xlsx" + return FileResponse( + output, + as_attachment=True, + filename=filename, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + @action(detail=False, methods=['post'], parser_classes=[MultiPartParser], url_path='import-excel') def import_excel(self, request): if request.user.role != 'admin': diff --git a/backend/tmp_validation_test.xlsx b/backend/tmp_validation_test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4d99ff70cc7a142cfef5718484dad7dda092e973 GIT binary patch literal 5048 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 G0{jPGsmEpj literal 0 HcmV?d00001 diff --git a/backend/tmp_validation_test_2.xlsx b/backend/tmp_validation_test_2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4b207217a8f0a7d08489225617f5579817d68562 GIT binary patch literal 5180 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 G0sjYYouTOf literal 0 HcmV?d00001 diff --git a/backend/tmp_validation_test_4.xlsx b/backend/tmp_validation_test_4.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..dbb2d62b583e02b5f1d8c9db7ede0ae0a9eb3f41 GIT binary patch literal 5365 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 { return data } +export const exportMaterialsExcel = async (params) => { + const response = await api.get('/material/export-excel/', { + params, + responseType: 'blob', + }) + return response +} + 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 b1b1e97..3c46eff 100644 --- a/frontend/src/views/MaterialManage.vue +++ b/frontend/src/views/MaterialManage.vue @@ -13,14 +13,17 @@ {{ importing ? '导入中...' : '导入数据' }} + + {{ exporting ? '导出中...' : '导出' }} + 新增材料
- - + + @@ -29,7 +32,7 @@ - +