fix:材料表导入
This commit is contained in:
parent
eb85d3a78a
commit
e3c626aba2
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue