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 0000000..4d99ff7 Binary files /dev/null and b/backend/tmp_validation_test.xlsx differ diff --git a/backend/tmp_validation_test_2.xlsx b/backend/tmp_validation_test_2.xlsx new file mode 100644 index 0000000..4b20721 Binary files /dev/null and b/backend/tmp_validation_test_2.xlsx differ diff --git a/backend/tmp_validation_test_3.xlsx b/backend/tmp_validation_test_3.xlsx new file mode 100644 index 0000000..d342a77 Binary files /dev/null and b/backend/tmp_validation_test_3.xlsx differ diff --git a/backend/tmp_validation_test_4.xlsx b/backend/tmp_validation_test_4.xlsx new file mode 100644 index 0000000..dbb2d62 Binary files /dev/null and b/backend/tmp_validation_test_4.xlsx differ diff --git a/frontend/src/api/material.js b/frontend/src/api/material.js index d6fe02e..4449e6a 100644 --- a/frontend/src/api/material.js +++ b/frontend/src/api/material.js @@ -38,6 +38,14 @@ export const importMaterialsExcel = async (file) => { 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 @@ - +