修复证书导入模板识别与文件重选
This commit is contained in:
@@ -17,7 +17,11 @@ from app.db.session import get_db
|
|||||||
from app.models import AdminUser, ImportBatch, ImportBatchRow, Learner, ProjectCourse
|
from app.models import AdminUser, ImportBatch, ImportBatchRow, Learner, ProjectCourse
|
||||||
from app.schemas.import_batch import ImportBatchOut
|
from app.schemas.import_batch import ImportBatchOut
|
||||||
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
|
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
|
||||||
from app.services.certificate_templates import CertificateTemplateDefinition, get_certificate_template
|
from app.services.certificate_templates import (
|
||||||
|
CertificateTemplateDefinition,
|
||||||
|
get_certificate_template,
|
||||||
|
list_certificate_templates,
|
||||||
|
)
|
||||||
from app.services.learner_identity import normalize_phone
|
from app.services.learner_identity import normalize_phone
|
||||||
from app.services.logs import log_action
|
from app.services.logs import log_action
|
||||||
|
|
||||||
@@ -99,6 +103,14 @@ def upload_import_file(
|
|||||||
with upload_path.open("wb") as target:
|
with upload_path.open("wb") as target:
|
||||||
shutil.copyfileobj(file.file, target)
|
shutil.copyfileobj(file.file, target)
|
||||||
|
|
||||||
|
detected_template = detect_template_from_workbook(upload_path)
|
||||||
|
if detected_template and detected_template.code != template.code:
|
||||||
|
upload_path.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"文件列属于“{detected_template.name}”,当前选择的是“{template.name}”。请切换证书模板后重新上传。",
|
||||||
|
)
|
||||||
|
|
||||||
batch = ImportBatch(
|
batch = ImportBatch(
|
||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
file_path=str(upload_path),
|
file_path=str(upload_path),
|
||||||
@@ -332,6 +344,20 @@ def required_headers(template: CertificateTemplateDefinition) -> list[str]:
|
|||||||
return COMMON_HEADERS + [FIELD_COLUMNS[field.key] for field in template.fields if field.key != "learner_name" and field.required]
|
return COMMON_HEADERS + [FIELD_COLUMNS[field.key] for field in template.fields if field.key != "learner_name" and field.required]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_template_from_workbook(upload_path: Path) -> CertificateTemplateDefinition | None:
|
||||||
|
workbook = load_workbook(upload_path, read_only=True, data_only=True)
|
||||||
|
try:
|
||||||
|
sheet = workbook.active
|
||||||
|
first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
||||||
|
header_names = {str(value).strip() for value in (first_row or ()) if value is not None and str(value).strip()}
|
||||||
|
for template in list_certificate_templates():
|
||||||
|
if header_names == set(template_headers(template)):
|
||||||
|
return template
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
|
||||||
def optional_text(value: object) -> str | None:
|
def optional_text(value: object) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.api.routes.admin_imports import (
|
|||||||
TEMPLATE_HEADERS,
|
TEMPLATE_HEADERS,
|
||||||
build_import_template_workbook,
|
build_import_template_workbook,
|
||||||
date_is_valid,
|
date_is_valid,
|
||||||
|
detect_template_from_workbook,
|
||||||
normalize_row_data,
|
normalize_row_data,
|
||||||
parse_issue_date,
|
parse_issue_date,
|
||||||
row_errors,
|
row_errors,
|
||||||
@@ -50,6 +51,16 @@ def test_normalize_row_data_removes_time_from_excel_dates():
|
|||||||
assert row[COL_ISSUE_DATE] == "2026-07-05"
|
assert row[COL_ISSUE_DATE] == "2026-07-05"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_template_from_workbook_uses_excel_headers(tmp_path):
|
||||||
|
source_path = tmp_path / "practice-camp.xlsx"
|
||||||
|
build_import_template_workbook("practice-camp").save(source_path)
|
||||||
|
|
||||||
|
detected = detect_template_from_workbook(source_path)
|
||||||
|
|
||||||
|
assert detected is not None
|
||||||
|
assert detected.code == "practice-camp"
|
||||||
|
|
||||||
|
|
||||||
def test_row_errors_require_project_code_to_exist():
|
def test_row_errors_require_project_code_to_exist():
|
||||||
row = {
|
row = {
|
||||||
COL_NAME: "张三",
|
COL_NAME: "张三",
|
||||||
|
|||||||
@@ -28,19 +28,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-upload
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
class="upload"
|
class="upload"
|
||||||
drag
|
drag
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
:show-file-list="true"
|
:show-file-list="true"
|
||||||
:limit="1"
|
:limit="1"
|
||||||
|
:disabled="!selectedTemplate"
|
||||||
accept=".xlsx"
|
accept=".xlsx"
|
||||||
@change="pickFile"
|
@change="pickFile"
|
||||||
@remove="selectedFile = null"
|
@remove="clearSelectedFile"
|
||||||
|
@exceed="replaceFile"
|
||||||
>
|
>
|
||||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||||
<div>把 Excel 文件拖到这里,或点击选择文件</div>
|
<div>{{ selectedTemplate ? "把 Excel 文件拖到这里,或点击选择文件" : "请先选择证书模板" }}</div>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<el-button type="primary" :disabled="!selectedFile" :loading="uploading" @click="uploadFile">上传并校验</el-button>
|
<el-button type="primary" :disabled="!selectedFile || !selectedTemplate" :loading="uploading" @click="uploadFile">上传并校验</el-button>
|
||||||
|
|
||||||
<el-card class="panel" shadow="never">
|
<el-card class="panel" shadow="never">
|
||||||
<el-table :data="batches" border>
|
<el-table :data="batches" border>
|
||||||
@@ -111,7 +114,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { UploadFilled } from "@element-plus/icons-vue";
|
import { UploadFilled } from "@element-plus/icons-vue";
|
||||||
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
|
import {
|
||||||
|
ElMessage,
|
||||||
|
ElMessageBox,
|
||||||
|
genFileId,
|
||||||
|
type UploadFile,
|
||||||
|
type UploadInstance,
|
||||||
|
type UploadRawFile,
|
||||||
|
} from "element-plus";
|
||||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||||
|
|
||||||
import { http, type CertificateTemplate, type ImportBatch, type PdfPregenerationJob } from "../api";
|
import { http, type CertificateTemplate, type ImportBatch, type PdfPregenerationJob } from "../api";
|
||||||
@@ -119,8 +129,9 @@ import { apiErrorMessage, downloadFile } from "../download";
|
|||||||
|
|
||||||
const batches = ref<ImportBatch[]>([]);
|
const batches = ref<ImportBatch[]>([]);
|
||||||
const templates = ref<CertificateTemplate[]>([]);
|
const templates = ref<CertificateTemplate[]>([]);
|
||||||
const templateCode = ref("classic");
|
const templateCode = ref("");
|
||||||
const selectedFile = ref<File | null>(null);
|
const selectedFile = ref<File | null>(null);
|
||||||
|
const uploadRef = ref<UploadInstance>();
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const confirmingId = ref<number | null>(null);
|
const confirmingId = ref<number | null>(null);
|
||||||
const preGeneratingId = ref<number | null>(null);
|
const preGeneratingId = ref<number | null>(null);
|
||||||
@@ -155,6 +166,23 @@ function pickFile(file: UploadFile) {
|
|||||||
selectedFile.value = file.raw || null;
|
selectedFile.value = file.raw || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelectedFile() {
|
||||||
|
selectedFile.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUpload() {
|
||||||
|
selectedFile.value = null;
|
||||||
|
uploadRef.value?.clearFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFile(files: File[]) {
|
||||||
|
const file = files[0] as UploadRawFile | undefined;
|
||||||
|
if (!file) return;
|
||||||
|
uploadRef.value?.clearFiles();
|
||||||
|
file.uid = genFileId();
|
||||||
|
uploadRef.value?.handleStart(file);
|
||||||
|
}
|
||||||
|
|
||||||
function downloadTemplate() {
|
function downloadTemplate() {
|
||||||
if (!selectedTemplate.value) return;
|
if (!selectedTemplate.value) return;
|
||||||
downloadFile(
|
downloadFile(
|
||||||
@@ -180,10 +208,16 @@ async function uploadFile() {
|
|||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", selectedFile.value);
|
body.append("file", selectedFile.value);
|
||||||
body.append("template_code", templateCode.value);
|
body.append("template_code", templateCode.value);
|
||||||
await http.post("/admin/import-batches", body);
|
const { data } = await http.post<ImportBatch>("/admin/import-batches", body);
|
||||||
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
|
if (data.failed_rows) {
|
||||||
selectedFile.value = null;
|
ElMessage.warning(`校验完成:${data.valid_rows} 行可导入,${data.failed_rows} 行失败,请下载错误报告查看原因`);
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`校验通过,共 ${data.valid_rows} 行可导入`);
|
||||||
|
}
|
||||||
|
resetUpload();
|
||||||
await loadBatches();
|
await loadBatches();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(await apiErrorMessage(error, "上传校验失败"));
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false;
|
uploading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user