fix:材料表导入

This commit is contained in:
shijing 2026-03-19 11:14:57 +08:00
parent eb85d3a78a
commit e3c626aba2
5 changed files with 246 additions and 1 deletions

View File

@ -0,0 +1,181 @@
import re
from typing import Any, Dict, List, Optional, Tuple
import openpyxl
from apps.factory.models import Factory
from apps.material.models import Material
MAJOR_CATEGORY_MAP = {
"建筑": "architecture",
"景观": "landscape",
"设备": "equipment",
"装修": "decoration",
"室内": "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,,、/;]+")
def _cell(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _single_line(value: Any, max_len: int = 255) -> str:
text = _cell(value)
if not text:
return ""
text = re.sub(r"\s+", " ", text)
return text[:max_len].strip()
def _parse_choice(value: Any, allowed_values: set) -> Optional[str]:
text = _cell(value)
if not text:
return None
return text if text in allowed_values else None
def _resolve_factory(unit_name: str, factory_cache: Dict[str, Optional[Factory]], unrecognized_factory: Factory) -> Tuple[Factory, bool, bool]:
if not unit_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]
matched_factory = None
for term in search_terms:
matched_factory = Factory.objects.filter(brand=term).first()
if matched_factory:
break
matched_factory = Factory.objects.filter(brand__icontains=term).first()
if matched_factory:
break
term_lower = term.lower()
for factory in Factory.objects.all():
brand = (factory.brand or "").lower()
if brand and brand in term_lower:
matched_factory = factory
break
if matched_factory:
break
factory_cache[unit_name] = matched_factory
factory = factory_cache[unit_name]
if factory:
return factory, False, False
created_factory = Factory.objects.create(
factory_name=unit_name,
brand=unit_name,
province="北京",
city="北京",
district="北京",
)
factory_cache[unit_name] = created_factory
return created_factory, False, True
def import_materials_plan_excel(file_obj) -> Dict[str, int]:
workbook = openpyxl.load_workbook(file_obj, read_only=True, data_only=True)
worksheet = workbook[workbook.sheetnames[0]]
rows = list(worksheet.iter_rows(values_only=True))
workbook.close()
if len(rows) < 3:
raise ValueError("Excel 内容不足,未找到表头或数据。")
header = [_cell(value) for value in rows[1]]
header_index = {name: idx for idx, name in enumerate(header) if name}
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])
unrecognized_factory, _ = Factory.objects.get_or_create(
brand="未识别的品牌",
defaults={
"factory_name": "未识别的品牌工厂",
"province": "-",
"city": "-",
},
)
created = 0
updated = 0
skipped = 0
unresolved_factory = 0
created_factory = 0
factory_cache: Dict[str, Optional[Factory]] = {}
current_major_category = ""
for row in rows[2:]:
if not row:
continue
row_values = [_cell(value) for value in row]
if not any(row_values):
continue
major_raw = get(row, "材料大类")
if major_raw:
current_major_category = major_raw
material_name = _single_line(get(row, "材料名称"))
material_category = _single_line(get(row, "细分种类"))
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)
if is_unresolved:
unresolved_factory += 1
if is_created_factory:
created_factory += 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,
}
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,
)
if created_flag:
created += 1
else:
updated += 1
return {
"created": created,
"updated": updated,
"skipped": skipped,
"unresolved_factory": unresolved_factory,
"created_factory": created_factory,
}

View File

@ -4,8 +4,10 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import MultiPartParser
from .models import Material, MaterialCategory, MaterialSubcategory
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
from .importers import import_materials_plan_excel
class MaterialViewSet(ModelViewSet):
@ -199,6 +201,27 @@ class MaterialViewSet(ModelViewSet):
'status': Material.STATUS_CHOICES,
})
@action(detail=False, methods=['post'], parser_classes=[MultiPartParser], url_path='import-excel')
def import_excel(self, request):
if request.user.role != 'admin':
raise PermissionDenied("只有管理员可以导入材料")
excel_file = request.FILES.get('file')
if not excel_file:
return Response({"detail": "未提供 Excel 文件"}, status=status.HTTP_400_BAD_REQUEST)
if not excel_file.name.lower().endswith('.xlsx'):
return Response({"detail": "仅支持 .xlsx 格式的 Excel 文件"}, status=status.HTTP_400_BAD_REQUEST)
try:
result = import_materials_plan_excel(excel_file)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc:
return Response({"detail": f"导入失败: {exc}"}, status=status.HTTP_400_BAD_REQUEST)
return Response(result)
class MaterialCategoryViewSet(ModelViewSet):
"""

Binary file not shown.

View File

@ -29,6 +29,15 @@ export const uploadImage = async (file) => {
return data
}
export const importMaterialsExcel = async (file) => {
const formData = new FormData()
formData.append('file', file)
const { data } = await api.post('/material/import-excel/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export const deleteMaterial = async (id) => {
const { data } = await api.delete(`/material/${id}/`)
return data

View File

@ -10,7 +10,20 @@
<el-option v-for="item in filterSubcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
<el-button @click="loadMaterials">查询</el-button>
<el-upload
v-if="isAdmin"
:auto-upload="true"
:show-file-list="false"
:http-request="handleImportExcel"
accept=".xlsx"
>
<el-button :loading="importing">{{ importing ? '导入中...' : '导入数据' }}</el-button>
</el-upload>
<el-button type="primary" @click="openCreate">新增材料</el-button>
<div class="toolbar-spacer" />
<a href="/material_import_template.xlsx" download="材料导入模板.xlsx">
<el-button>模板</el-button>
</a>
</div>
<el-table v-loading="tableLoading" :data="materials" border :max-height="560">
@ -201,7 +214,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 } from '@/api/material'
import { fetchMaterials, fetchMaterialDetail, createMaterial, updateMaterial, deleteMaterial, submitMaterial, approveMaterial, rejectMaterial, fetchMaterialChoices, uploadImage, importMaterialsExcel } from '@/api/material'
import { fetchCategories, fetchSubcategories } from '@/api/category'
import { fetchFactorySimple } from '@/api/factory'
@ -220,6 +233,7 @@ const dialogTitle = ref('')
const isEdit = ref(false)
const currentId = ref(null)
const uploading = ref(false)
const importing = ref(false)
const filters = reactive({
name: '',
@ -437,6 +451,20 @@ const onSave = async () => {
}
}
const handleImportExcel = async (options) => {
importing.value = true
try {
const result = await importMaterialsExcel(options.file)
pagination.page = 1
await loadMaterials()
ElMessage.success(`导入完成:新增 ${result.created} 条,更新 ${result.updated} 条,跳过 ${result.skipped} 条,新建工厂 ${result.created_factory || 0}`)
} catch (error) {
ElMessage.error(error.response?.data?.detail || '导入失败')
} finally {
importing.value = false
}
}
const onDelete = (row) => {
ElMessageBox.confirm(`确认删除材料 ${row.name} 吗?`, '提示', { type: 'warning' })
.then(async () => {
@ -495,6 +523,10 @@ onMounted(() => {
</script>
<style scoped>
.toolbar-spacer {
flex: 1 1 auto;
}
.pagination {
margin-top: 16px;
display: flex;