fix:3.20日材料可导入,导入后导出系统存在字段导出,然后补充完善后再导入
This commit is contained in:
parent
da6cd31ce8
commit
c1758ca649
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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='更新时间')
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,14 +13,17 @@
|
|||
<el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true">
|
||||
{{ importing ? '导入中...' : '导入数据' }}
|
||||
</el-button>
|
||||
<el-button :loading="exporting" @click="handleExportExcel">
|
||||
{{ exporting ? '导出中...' : '导出' }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreate">新增材料</el-button>
|
||||
<div class="toolbar-spacer" />
|
||||
</div>
|
||||
|
||||
<el-table v-loading="tableLoading" :data="materials" border :max-height="560">
|
||||
<el-table-column prop="name" label="材料名称" />
|
||||
<el-table-column prop="major_category_display" label="专业类别" />
|
||||
<el-table-column prop="material_category" label="材料分类" />
|
||||
<el-table-column prop="major_category_display" label="材料大类" />
|
||||
<el-table-column prop="material_category" label="细分种类" />
|
||||
<el-table-column prop="material_subcategory" label="材料子类" />
|
||||
<el-table-column prop="stage_display" label="阶段" />
|
||||
<el-table-column prop="importance_level_display" label="重要等级" />
|
||||
|
|
@ -29,7 +32,7 @@
|
|||
<el-table-column prop="contact_phone" label="对接人联系方式" width="130px"/>
|
||||
<el-table-column prop="handler" label="经办人" />
|
||||
<el-table-column prop="remark" label="备注" />
|
||||
<el-table-column prop="brand" label="所属工厂" />
|
||||
<el-table-column prop="brand" label="材料单位名称" />
|
||||
<el-table-column prop="status_display" label="状态" width="120" />
|
||||
<el-table-column label="操作" width="320">
|
||||
<template #default="scope">
|
||||
|
|
@ -62,12 +65,12 @@
|
|||
<el-form-item label="材料名称" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="专业类别" required>
|
||||
<el-form-item label="材料大类" required>
|
||||
<el-select v-model="form.major_category">
|
||||
<el-option v-for="item in majorOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="材料分类" required>
|
||||
<el-form-item label="细分种类" required>
|
||||
<el-select v-model="form.material_category" filterable @change="onCategoryChange">
|
||||
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.name" :value="item.value" />
|
||||
</el-select>
|
||||
|
|
@ -186,7 +189,7 @@
|
|||
<el-form-item label="限制条件">
|
||||
<el-input v-model="form.limit_condition" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属工厂" v-if="isAdmin">
|
||||
<el-form-item label="材料单位名称" v-if="isAdmin">
|
||||
<el-select v-model="form.factory">
|
||||
<el-option v-for="item in factories" :key="item.id" :label="item.brand" :value="item.id" />
|
||||
</el-select>
|
||||
|
|
@ -224,7 +227,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, importMaterialsExcel } from '@/api/material'
|
||||
import { fetchMaterials, fetchMaterialDetail, createMaterial, updateMaterial, deleteMaterial, submitMaterial, approveMaterial, rejectMaterial, fetchMaterialChoices, uploadImage, importMaterialsExcel, exportMaterialsExcel } from '@/api/material'
|
||||
import { fetchCategories, fetchSubcategories } from '@/api/category'
|
||||
import { fetchFactorySimple } from '@/api/factory'
|
||||
|
||||
|
|
@ -245,11 +248,10 @@ const isEdit = ref(false)
|
|||
const currentId = ref(null)
|
||||
const uploading = ref(false)
|
||||
const importing = ref(false)
|
||||
const exporting = ref(false)
|
||||
// const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL).replace(/\/api$/, "");
|
||||
// const templateDownloadUrl = `${apiBaseUrl}/media/material_import_template.xlsx`
|
||||
const templateDownloadUrl = `http://101.42.1.64:2260/media/material_import_template.xlsx`
|
||||
|
||||
|
||||
const filters = reactive({
|
||||
name: '',
|
||||
status: '',
|
||||
|
|
@ -481,6 +483,50 @@ const handleImportExcel = async (options) => {
|
|||
}
|
||||
}
|
||||
|
||||
const getDownloadFilename = (headers, fallback) => {
|
||||
const disposition = headers?.['content-disposition'] || ''
|
||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (utf8Match?.[1]) {
|
||||
return decodeURIComponent(utf8Match[1])
|
||||
}
|
||||
const asciiMatch = disposition.match(/filename="?([^"]+)"?/i)
|
||||
if (asciiMatch?.[1]) {
|
||||
return asciiMatch[1]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
exporting.value = true
|
||||
try {
|
||||
const response = await exportMaterialsExcel({ ...filters })
|
||||
const blob = response.data instanceof Blob ? response.data : new Blob([response.data])
|
||||
const filename = getDownloadFilename(response.headers, 'materials_export.xlsx')
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('导出成功')
|
||||
} catch (error) {
|
||||
let detail = error.response?.data?.detail
|
||||
if (error.response?.data instanceof Blob) {
|
||||
const text = await error.response.data.text()
|
||||
try {
|
||||
detail = JSON.parse(text).detail || text
|
||||
} catch {
|
||||
detail = text
|
||||
}
|
||||
}
|
||||
ElMessage.error(detail || '导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除材料 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
|
|
@ -530,7 +576,6 @@ const onPageSizeChange = (size) => {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('templateDownloadUrl', templateDownloadUrl)
|
||||
loadChoices()
|
||||
loadCategories()
|
||||
loadSubcategories()
|
||||
|
|
|
|||
Loading…
Reference in New Issue