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 @@