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.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import MultiPartParser
from .models import Material, MaterialCategory, MaterialSubcategory from .models import Material, MaterialCategory, MaterialSubcategory
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
from .importers import import_materials_plan_excel
class MaterialViewSet(ModelViewSet): class MaterialViewSet(ModelViewSet):
@ -199,6 +201,27 @@ class MaterialViewSet(ModelViewSet):
'status': Material.STATUS_CHOICES, '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): class MaterialCategoryViewSet(ModelViewSet):
""" """

Binary file not shown.

View File

@ -29,6 +29,15 @@ export const uploadImage = async (file) => {
return data 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) => { export const deleteMaterial = async (id) => {
const { data } = await api.delete(`/material/${id}/`) const { data } = await api.delete(`/material/${id}/`)
return data 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-option v-for="item in filterSubcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select> </el-select>
<el-button @click="loadMaterials">查询</el-button> <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> <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> </div>
<el-table v-loading="tableLoading" :data="materials" border :max-height="560"> <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 { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuth } from '@/store/auth' 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 { fetchCategories, fetchSubcategories } from '@/api/category'
import { fetchFactorySimple } from '@/api/factory' import { fetchFactorySimple } from '@/api/factory'
@ -220,6 +233,7 @@ const dialogTitle = ref('')
const isEdit = ref(false) const isEdit = ref(false)
const currentId = ref(null) const currentId = ref(null)
const uploading = ref(false) const uploading = ref(false)
const importing = ref(false)
const filters = reactive({ const filters = reactive({
name: '', 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) => { const onDelete = (row) => {
ElMessageBox.confirm(`确认删除材料 ${row.name} 吗?`, '提示', { type: 'warning' }) ElMessageBox.confirm(`确认删除材料 ${row.name} 吗?`, '提示', { type: 'warning' })
.then(async () => { .then(async () => {
@ -495,6 +523,10 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.toolbar-spacer {
flex: 1 1 auto;
}
.pagination { .pagination {
margin-top: 16px; margin-top: 16px;
display: flex; display: flex;