fix:3.20日材料可导入,导入后导出系统存在字段导出,然后补充完善后再导入

This commit is contained in:
shijing 2026-03-23 13:52:13 +08:00
parent da6cd31ce8
commit c1758ca649
9 changed files with 533 additions and 71 deletions

View File

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

View File

@ -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='更新时间')

View File

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

View File

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

View File

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