重构学员证书签发与批量导入流程
This commit is contained in:
@@ -8,12 +8,11 @@ from app.core.security import generate_public_token, hash_token
|
|||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
|
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||||
from app.schemas.certificate import CertificateCreate, CertificateOut, PdfPregenerationJobCreate, PdfPregenerationJobOut
|
from app.schemas.certificate import CertificateCreate, CertificateOut, PdfPregenerationJobCreate, PdfPregenerationJobOut
|
||||||
from app.services.certificate_number import build_certificate_no
|
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
|
||||||
from app.services.logs import log_action
|
from app.services.logs import log_action
|
||||||
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
|
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
|
||||||
from app.services.pdf_pregeneration import pdf_pregeneration_manager
|
from app.services.pdf_pregeneration import pdf_pregeneration_manager
|
||||||
from app.services.system_settings import get_pdf_generation_concurrency_limit
|
from app.services.system_settings import get_pdf_generation_concurrency_limit
|
||||||
from app.services.certificate_templates import get_certificate_template
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -51,6 +50,7 @@ def list_certificates(
|
|||||||
Certificate.course_name.like(like),
|
Certificate.course_name.like(like),
|
||||||
Certificate.stage_name.like(like),
|
Certificate.stage_name.like(like),
|
||||||
Learner.current_name.like(like),
|
Learner.current_name.like(like),
|
||||||
|
Learner.phone.like(like),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status_value:
|
if status_value:
|
||||||
@@ -68,38 +68,34 @@ def create_certificate(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||||
) -> Certificate:
|
) -> Certificate:
|
||||||
learner = db.get(Learner, payload.learner_id)
|
|
||||||
if not learner:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
|
||||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project code is inactive or missing")
|
|
||||||
template_code = payload.template_code or project.default_template_code
|
|
||||||
try:
|
try:
|
||||||
template_code = get_certificate_template(template_code).code
|
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
|
||||||
|
if not project:
|
||||||
|
raise ValueError("项目代码不存在或已停用")
|
||||||
|
certificate, learner, learner_created = issue_certificate(
|
||||||
|
db,
|
||||||
|
CertificateIssueData(
|
||||||
|
learner_name=payload.learner_name,
|
||||||
|
learner_phone=payload.learner_phone,
|
||||||
|
project_code=payload.project_code,
|
||||||
|
class_name=payload.class_name,
|
||||||
|
course_name=payload.course_name,
|
||||||
|
stage_name=payload.stage_name,
|
||||||
|
course_start_date=payload.course_start_date,
|
||||||
|
course_end_date=payload.course_end_date,
|
||||||
|
issue_date=payload.issue_date,
|
||||||
|
template_code=payload.template_code or project.default_template_code,
|
||||||
|
remark=payload.remark,
|
||||||
|
),
|
||||||
|
source="manual_certificate",
|
||||||
|
)
|
||||||
|
except DuplicateCertificate as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
except (ValueError, FileNotFoundError) as exc:
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
db.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
log_action(db, admin, "create_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "learner_created": learner_created, "project_code": certificate.project_code, "issue_date": certificate.issue_date})
|
||||||
certificate = Certificate(
|
|
||||||
learner_id=payload.learner_id,
|
|
||||||
project_code=payload.project_code,
|
|
||||||
certificate_no="PENDING",
|
|
||||||
certificate_name=project.name,
|
|
||||||
class_name=payload.class_name,
|
|
||||||
course_name=payload.course_name or project.default_course_name or project.name,
|
|
||||||
stage_name=payload.stage_name or project.default_stage_name,
|
|
||||||
course_start_date=payload.course_start_date,
|
|
||||||
course_end_date=payload.course_end_date,
|
|
||||||
issue_date=payload.issue_date,
|
|
||||||
template_code=template_code,
|
|
||||||
remark=payload.remark,
|
|
||||||
)
|
|
||||||
db.add(certificate)
|
|
||||||
db.flush()
|
|
||||||
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
|
|
||||||
certificate.public_token_id = _create_token(db, certificate.id, "public_link")
|
|
||||||
certificate.qr_token_id = _create_token(db, certificate.id, "qr_verify")
|
|
||||||
log_action(db, admin, "create_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code, "issue_date": certificate.issue_date})
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(certificate)
|
db.refresh(certificate)
|
||||||
return certificate
|
return certificate
|
||||||
@@ -167,6 +163,7 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
|
|||||||
"id": certificate.id,
|
"id": certificate.id,
|
||||||
"learner_id": certificate.learner_id,
|
"learner_id": certificate.learner_id,
|
||||||
"learner_name": learner.current_name if learner else None,
|
"learner_name": learner.current_name if learner else None,
|
||||||
|
"learner_phone": learner.phone if learner else None,
|
||||||
"import_batch_id": certificate.import_batch_id,
|
"import_batch_id": certificate.import_batch_id,
|
||||||
"project_code": certificate.project_code,
|
"project_code": certificate.project_code,
|
||||||
"certificate_no": certificate.certificate_no,
|
"certificate_no": certificate.certificate_no,
|
||||||
@@ -240,6 +237,7 @@ def preview_certificate(
|
|||||||
return {
|
return {
|
||||||
"certificate_no": certificate.certificate_no,
|
"certificate_no": certificate.certificate_no,
|
||||||
"learner_name": learner.current_name if learner else "",
|
"learner_name": learner.current_name if learner else "",
|
||||||
|
"learner_phone": learner.phone if learner else "",
|
||||||
"certificate_name": certificate.certificate_name,
|
"certificate_name": certificate.certificate_name,
|
||||||
"project_code": certificate.project_code,
|
"project_code": certificate.project_code,
|
||||||
"course_name": certificate.course_name,
|
"course_name": certificate.course_name,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import shutil
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
from openpyxl.comments import Comment
|
from openpyxl.comments import Comment
|
||||||
@@ -13,36 +13,36 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import require_roles
|
from app.api.deps import require_roles
|
||||||
from app.core.paths import data_path
|
from app.core.paths import data_path
|
||||||
from app.core.security import generate_public_token, hash_token
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models import (
|
from app.models import AdminUser, ImportBatch, ImportBatchRow, Learner, ProjectCourse
|
||||||
AdminUser,
|
|
||||||
Certificate,
|
|
||||||
CertificateAccessToken,
|
|
||||||
ImportBatch,
|
|
||||||
ImportBatchRow,
|
|
||||||
Learner,
|
|
||||||
LearnerNameHistory,
|
|
||||||
ProjectCourse,
|
|
||||||
)
|
|
||||||
from app.schemas.import_batch import ImportBatchOut
|
from app.schemas.import_batch import ImportBatchOut
|
||||||
from app.services.certificate_number import build_certificate_no
|
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
|
||||||
|
from app.services.certificate_templates import CertificateTemplateDefinition, get_certificate_template
|
||||||
|
from app.services.learner_identity import normalize_phone
|
||||||
from app.services.logs import log_action
|
from app.services.logs import log_action
|
||||||
from app.services.certificate_templates import get_certificate_template
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
COL_NAME = "\u59d3\u540d"
|
COL_NAME = "姓名"
|
||||||
COL_PHONE = "\u624b\u673a\u53f7"
|
COL_PHONE = "手机号"
|
||||||
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
|
COL_PROJECT = "项目代码"
|
||||||
|
COL_COURSE_NAME = "课程名称"
|
||||||
|
COL_STAGE_NAME = "阶段名称"
|
||||||
COL_COURSE_START_DATE = "课程开始日期"
|
COL_COURSE_START_DATE = "课程开始日期"
|
||||||
COL_COURSE_END_DATE = "课程结束日期"
|
COL_COURSE_END_DATE = "课程结束日期"
|
||||||
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
|
COL_ISSUE_DATE = "发证日期"
|
||||||
|
|
||||||
TEMPLATE_HEADERS = [
|
COMMON_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT]
|
||||||
COL_NAME,
|
FIELD_COLUMNS = {
|
||||||
COL_PHONE,
|
"course_name": COL_COURSE_NAME,
|
||||||
COL_PROJECT,
|
"stage_name": COL_STAGE_NAME,
|
||||||
|
"course_start_date": COL_COURSE_START_DATE,
|
||||||
|
"course_end_date": COL_COURSE_END_DATE,
|
||||||
|
"issue_date": COL_ISSUE_DATE,
|
||||||
|
}
|
||||||
|
TEMPLATE_HEADERS = COMMON_HEADERS + [
|
||||||
|
COL_COURSE_NAME,
|
||||||
|
COL_STAGE_NAME,
|
||||||
COL_COURSE_START_DATE,
|
COL_COURSE_START_DATE,
|
||||||
COL_COURSE_END_DATE,
|
COL_COURSE_END_DATE,
|
||||||
COL_ISSUE_DATE,
|
COL_ISSUE_DATE,
|
||||||
@@ -51,25 +51,33 @@ REQUIRED_HEADERS = TEMPLATE_HEADERS
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/template")
|
@router.get("/template")
|
||||||
def download_template(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
|
def download_template(
|
||||||
workbook = build_import_template_workbook()
|
template_code: str = Query(default="classic"),
|
||||||
stream_path = data_path("exports") / "certificate-import-template.xlsx"
|
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
try:
|
||||||
|
template = get_certificate_template(template_code)
|
||||||
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
workbook = build_import_template_workbook(template.code)
|
||||||
|
stream_path = data_path("exports") / f"certificate-import-{template.code}.xlsx"
|
||||||
workbook.save(stream_path)
|
workbook.save(stream_path)
|
||||||
file_handle = stream_path.open("rb")
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
file_handle,
|
stream_path.open("rb"),
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
headers={"Content-Disposition": 'attachment; filename="certificate-import-template.xlsx"'},
|
headers={"Content-Disposition": f'attachment; filename="certificate-import-{template.code}.xlsx"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_import_template_workbook() -> Workbook:
|
def build_import_template_workbook(template_code: str = "classic") -> Workbook:
|
||||||
|
template = get_certificate_template(template_code)
|
||||||
|
headers = template_headers(template)
|
||||||
workbook = Workbook()
|
workbook = Workbook()
|
||||||
sheet = workbook.active
|
sheet = workbook.active
|
||||||
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
|
sheet.title = "证书导入模板"
|
||||||
sheet.append(TEMPLATE_HEADERS)
|
sheet.append(headers)
|
||||||
_format_template_sheet(sheet)
|
_format_template_sheet(sheet, headers, template)
|
||||||
_add_template_instructions(workbook)
|
_add_template_instructions(workbook, template)
|
||||||
return workbook
|
return workbook
|
||||||
|
|
||||||
|
|
||||||
@@ -81,21 +89,33 @@ def upload_import_file(
|
|||||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||||
) -> ImportBatch:
|
) -> ImportBatch:
|
||||||
if not file.filename or not file.filename.lower().endswith(".xlsx"):
|
if not file.filename or not file.filename.lower().endswith(".xlsx"):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only .xlsx files are supported")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 .xlsx 文件")
|
||||||
try:
|
try:
|
||||||
template_code = get_certificate_template(template_code).code
|
template = get_certificate_template(template_code)
|
||||||
except (ValueError, FileNotFoundError) as exc:
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
upload_path = data_path("uploads") / file.filename
|
upload_path = data_path("uploads") / f"{datetime.now():%Y%m%d%H%M%S}-{Path(file.filename).name}"
|
||||||
with upload_path.open("wb") as target:
|
with upload_path.open("wb") as target:
|
||||||
shutil.copyfileobj(file.file, target)
|
shutil.copyfileobj(file.file, target)
|
||||||
|
|
||||||
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), template_code=template_code, created_by=admin.id)
|
batch = ImportBatch(
|
||||||
|
filename=file.filename,
|
||||||
|
file_path=str(upload_path),
|
||||||
|
template_code=template.code,
|
||||||
|
created_by=admin.id,
|
||||||
|
)
|
||||||
db.add(batch)
|
db.add(batch)
|
||||||
db.flush()
|
db.flush()
|
||||||
validate_batch(db, batch, upload_path)
|
validate_batch(db, batch, upload_path)
|
||||||
log_action(db, admin, "upload_import_file", "import_batch", batch.id, {"filename": file.filename, "total_rows": batch.total_rows, "valid_rows": batch.valid_rows, "failed_rows": batch.failed_rows, "status": batch.status})
|
log_action(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
"upload_import_file",
|
||||||
|
"import_batch",
|
||||||
|
batch.id,
|
||||||
|
{"filename": file.filename, "template_code": template.code, "total_rows": batch.total_rows, "valid_rows": batch.valid_rows, "failed_rows": batch.failed_rows, "status": batch.status},
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(batch)
|
db.refresh(batch)
|
||||||
return batch
|
return batch
|
||||||
@@ -117,12 +137,8 @@ def download_error_report(
|
|||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
batch = db.get(ImportBatch, batch_id)
|
batch = db.get(ImportBatch, batch_id)
|
||||||
if not batch or not batch.error_report_path:
|
if not batch or not batch.error_report_path:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Error report not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错误报告不存在")
|
||||||
return FileResponse(
|
return FileResponse(batch.error_report_path, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename=f"import-errors-{batch.id}.xlsx")
|
||||||
batch.error_report_path,
|
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
filename=f"import-errors-{batch.id}.xlsx",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{batch_id}/file")
|
@router.get("/{batch_id}/file")
|
||||||
@@ -133,12 +149,8 @@ def download_source_file(
|
|||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
batch = db.get(ImportBatch, batch_id)
|
batch = db.get(ImportBatch, batch_id)
|
||||||
if not batch or not Path(batch.file_path).exists():
|
if not batch or not Path(batch.file_path).exists():
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source file not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="原始文件不存在")
|
||||||
return FileResponse(
|
return FileResponse(batch.file_path, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename=batch.filename)
|
||||||
batch.file_path,
|
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
filename=batch.filename,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{batch_id}")
|
@router.delete("/{batch_id}")
|
||||||
@@ -149,14 +161,10 @@ def delete_import_batch(
|
|||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
batch = db.get(ImportBatch, batch_id)
|
batch = db.get(ImportBatch, batch_id)
|
||||||
if not batch:
|
if not batch:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Import batch not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导入批次不存在")
|
||||||
|
|
||||||
for file_name in [batch.file_path, batch.error_report_path]:
|
for file_name in [batch.file_path, batch.error_report_path]:
|
||||||
if file_name:
|
if file_name and Path(file_name).exists():
|
||||||
path = Path(file_name)
|
Path(file_name).unlink()
|
||||||
if path.exists():
|
|
||||||
path.unlink()
|
|
||||||
|
|
||||||
db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id).delete()
|
db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id).delete()
|
||||||
db.delete(batch)
|
db.delete(batch)
|
||||||
log_action(db, admin, "delete_import_batch", "import_batch", batch.id, {"filename": batch.filename, "status": batch.status, "total_rows": batch.total_rows})
|
log_action(db, admin, "delete_import_batch", "import_batch", batch.id, {"filename": batch.filename, "status": batch.status, "total_rows": batch.total_rows})
|
||||||
@@ -172,84 +180,73 @@ def confirm_import_batch(
|
|||||||
) -> ImportBatch:
|
) -> ImportBatch:
|
||||||
batch = db.get(ImportBatch, batch_id)
|
batch = db.get(ImportBatch, batch_id)
|
||||||
if not batch:
|
if not batch:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Import batch not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导入批次不存在")
|
||||||
if batch.status not in {"validated", "imported"}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Import batch is not ready")
|
|
||||||
if batch.status == "imported":
|
if batch.status == "imported":
|
||||||
return batch
|
return batch
|
||||||
|
if batch.status != "validated":
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前批次不能确认导入")
|
||||||
|
|
||||||
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id, ImportBatchRow.status == "valid").all()
|
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id, ImportBatchRow.status == "valid").all()
|
||||||
ok_rows = 0
|
imported_rows = skipped_rows = failed_rows = created_learners = 0
|
||||||
failed_rows = 0
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row_data = json.loads(row.raw_json or "{}")
|
row_data = json.loads(row.raw_json or "{}")
|
||||||
learner = upsert_learner(db, row_data)
|
try:
|
||||||
course_start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
|
_, _, learner_created = issue_certificate(
|
||||||
course_end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
|
db,
|
||||||
issue_date = parse_issue_date(row_data[COL_ISSUE_DATE])
|
CertificateIssueData(
|
||||||
project_code = str(row_data[COL_PROJECT]).strip().upper()
|
learner_name=str(row_data[COL_NAME]),
|
||||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
|
learner_phone=str(row_data[COL_PHONE]),
|
||||||
if not project:
|
project_code=str(row_data[COL_PROJECT]),
|
||||||
row.status = "failed"
|
template_code=batch.template_code,
|
||||||
row.error_message = f"Project code is inactive or missing: {project_code}"
|
course_name=optional_text(row_data.get(COL_COURSE_NAME)),
|
||||||
failed_rows += 1
|
stage_name=optional_text(row_data.get(COL_STAGE_NAME)),
|
||||||
continue
|
course_start_date=parse_optional_date(row_data.get(COL_COURSE_START_DATE), COL_COURSE_START_DATE),
|
||||||
duplicate = find_duplicate_certificate(
|
course_end_date=parse_optional_date(row_data.get(COL_COURSE_END_DATE), COL_COURSE_END_DATE),
|
||||||
db,
|
issue_date=parse_issue_date(row_data[COL_ISSUE_DATE]),
|
||||||
learner.id,
|
import_batch_id=batch.id,
|
||||||
project,
|
),
|
||||||
course_start_date,
|
source="certificate_import",
|
||||||
course_end_date,
|
)
|
||||||
issue_date,
|
row.status = "imported"
|
||||||
batch.template_code,
|
imported_rows += 1
|
||||||
)
|
created_learners += int(learner_created)
|
||||||
if duplicate:
|
except DuplicateCertificate as exc:
|
||||||
row.status = "skipped"
|
row.status = "skipped"
|
||||||
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
|
row.error_message = str(exc)
|
||||||
ok_rows += 1
|
skipped_rows += 1
|
||||||
continue
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
row.status = "failed"
|
||||||
|
row.error_message = str(exc)
|
||||||
|
failed_rows += 1
|
||||||
|
|
||||||
certificate = Certificate(
|
batch.status = "imported" if imported_rows or skipped_rows else "failed"
|
||||||
learner_id=learner.id,
|
batch.failed_rows = (batch.failed_rows or 0) + failed_rows
|
||||||
import_batch_id=batch.id,
|
log_action(
|
||||||
project_code=project.code,
|
db,
|
||||||
certificate_no="PENDING",
|
admin,
|
||||||
certificate_name=project.default_certificate_name,
|
"confirm_import_batch",
|
||||||
course_name=project.default_course_name,
|
"import_batch",
|
||||||
stage_name=project.default_stage_name,
|
batch.id,
|
||||||
course_start_date=course_start_date,
|
{"filename": batch.filename, "imported_rows": imported_rows, "skipped_rows": skipped_rows, "created_learners": created_learners, "failed_rows": failed_rows, "status": batch.status},
|
||||||
course_end_date=course_end_date,
|
)
|
||||||
issue_date=issue_date,
|
|
||||||
template_code=batch.template_code,
|
|
||||||
remark=None,
|
|
||||||
)
|
|
||||||
db.add(certificate)
|
|
||||||
db.flush()
|
|
||||||
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
|
|
||||||
certificate.public_token_id = create_access_token(db, certificate.id, "public_link")
|
|
||||||
certificate.qr_token_id = create_access_token(db, certificate.id, "qr_verify")
|
|
||||||
row.status = "imported"
|
|
||||||
ok_rows += 1
|
|
||||||
|
|
||||||
batch.status = "imported" if ok_rows else "failed"
|
|
||||||
if failed_rows:
|
|
||||||
batch.failed_rows = (batch.failed_rows or 0) + failed_rows
|
|
||||||
log_action(db, admin, "confirm_import_batch", "import_batch", batch.id, {"filename": batch.filename, "valid_rows": batch.valid_rows, "imported_rows": ok_rows, "failed_rows": failed_rows, "status": batch.status})
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(batch)
|
db.refresh(batch)
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
|
||||||
def validate_batch(db: Session, batch: ImportBatch, upload_path: Path) -> None:
|
def validate_batch(db: Session, batch: ImportBatch, upload_path: Path) -> None:
|
||||||
|
template = get_certificate_template(batch.template_code)
|
||||||
|
headers = template_headers(template)
|
||||||
workbook = load_workbook(upload_path, read_only=True, data_only=True)
|
workbook = load_workbook(upload_path, read_only=True, data_only=True)
|
||||||
sheet = workbook.active
|
sheet = workbook.active
|
||||||
header = [cell.value for cell in next(sheet.iter_rows(min_row=1, max_row=1))]
|
first_row = next(sheet.iter_rows(min_row=1, max_row=1), None)
|
||||||
|
header = [cell.value for cell in first_row] if first_row else []
|
||||||
header_index = {name: idx for idx, name in enumerate(header)}
|
header_index = {name: idx for idx, name in enumerate(header)}
|
||||||
missing = [name for name in TEMPLATE_HEADERS if name not in header_index]
|
missing = [name for name in required_headers(template) if name not in header_index]
|
||||||
if missing:
|
if missing:
|
||||||
batch.status = "failed"
|
batch.status = "failed"
|
||||||
batch.failed_rows = 1
|
batch.failed_rows = 1
|
||||||
db.add(ImportBatchRow(batch_id=batch.id, row_no=1, status="failed", error_message=f"Missing columns: {missing}"))
|
db.add(ImportBatchRow(batch_id=batch.id, row_no=1, status="failed", error_message=f"缺少必填列:{'、'.join(missing)}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
active_codes = {row[0] for row in db.query(ProjectCourse.code).filter(ProjectCourse.status == "active").all()}
|
active_codes = {row[0] for row in db.query(ProjectCourse.code).filter(ProjectCourse.status == "active").all()}
|
||||||
@@ -258,116 +255,70 @@ def validate_batch(db: Session, batch: ImportBatch, upload_path: Path) -> None:
|
|||||||
if not any(row):
|
if not any(row):
|
||||||
continue
|
continue
|
||||||
total += 1
|
total += 1
|
||||||
row_data = {name: row[header_index[name]] for name in TEMPLATE_HEADERS}
|
row_data = {name: row[header_index[name]] if name in header_index and header_index[name] < len(row) else None for name in headers}
|
||||||
errors = row_errors(row_data, active_codes)
|
errors = row_errors(row_data, active_codes, template.code, db)
|
||||||
if errors:
|
row_status = "failed" if errors else "valid"
|
||||||
failed += 1
|
failed += int(bool(errors))
|
||||||
db.add(
|
valid += int(not errors)
|
||||||
ImportBatchRow(
|
db.add(
|
||||||
batch_id=batch.id,
|
ImportBatchRow(
|
||||||
row_no=row_no,
|
batch_id=batch.id,
|
||||||
status="failed",
|
row_no=row_no,
|
||||||
error_message="; ".join(errors),
|
status=row_status,
|
||||||
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
|
error_message=";".join(errors) if errors else None,
|
||||||
)
|
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
|
||||||
)
|
)
|
||||||
else:
|
)
|
||||||
valid += 1
|
|
||||||
db.add(
|
|
||||||
ImportBatchRow(
|
|
||||||
batch_id=batch.id,
|
|
||||||
row_no=row_no,
|
|
||||||
status="valid",
|
|
||||||
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
batch.total_rows = total
|
batch.total_rows = total
|
||||||
batch.valid_rows = valid
|
batch.valid_rows = valid
|
||||||
batch.failed_rows = failed
|
batch.failed_rows = failed
|
||||||
batch.status = "validated"
|
batch.status = "validated" if total else "failed"
|
||||||
if failed:
|
if failed:
|
||||||
batch.error_report_path = str(write_error_report(db, batch.id))
|
batch.error_report_path = str(write_error_report(db, batch.id))
|
||||||
|
|
||||||
|
|
||||||
def row_errors(row_data: dict[str, object], active_codes: set[str]) -> list[str]:
|
def row_errors(
|
||||||
errors = []
|
row_data: dict[str, object],
|
||||||
for name in REQUIRED_HEADERS:
|
active_codes: set[str],
|
||||||
if not row_data.get(name):
|
template_code: str = "classic",
|
||||||
errors.append(f"{name} is required")
|
db: Session | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
template = get_certificate_template(template_code)
|
||||||
|
errors: list[str] = []
|
||||||
|
for name in required_headers(template):
|
||||||
|
if not optional_text(row_data.get(name)):
|
||||||
|
errors.append(f"{name}不能为空")
|
||||||
project_code = str(row_data.get(COL_PROJECT) or "").strip().upper()
|
project_code = str(row_data.get(COL_PROJECT) or "").strip().upper()
|
||||||
if project_code and project_code not in active_codes:
|
if project_code and project_code not in active_codes:
|
||||||
errors.append("Project code is inactive or missing")
|
errors.append("项目代码不存在或已停用")
|
||||||
for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE, COL_ISSUE_DATE]:
|
for field in template.fields:
|
||||||
|
if field.field_type != "date" or field.key == "learner_name":
|
||||||
|
continue
|
||||||
|
column = FIELD_COLUMNS[field.key]
|
||||||
if row_data.get(column) and not date_is_valid(row_data[column]):
|
if row_data.get(column) and not date_is_valid(row_data[column]):
|
||||||
errors.append(f"{column}格式错误,请使用YYYY-MM-DD,例如2026-06-01")
|
errors.append(f"{column}格式错误,请使用YYYY-MM-DD,例如{field.example}")
|
||||||
if all(row_data.get(column) and date_is_valid(row_data[column]) for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE]):
|
if all(row_data.get(column) and date_is_valid(row_data[column]) for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE]):
|
||||||
start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
|
if parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE) < parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE):
|
||||||
end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
|
|
||||||
if end_date < start_date:
|
|
||||||
errors.append("课程结束日期不能早于课程开始日期")
|
errors.append("课程结束日期不能早于课程开始日期")
|
||||||
|
if row_data.get(COL_PHONE):
|
||||||
|
try:
|
||||||
|
phone = normalize_phone(str(row_data[COL_PHONE]))
|
||||||
|
if db:
|
||||||
|
learner = db.query(Learner).filter(Learner.phone == phone, Learner.status != "deleted").first()
|
||||||
|
input_name = str(row_data.get(COL_NAME) or "").strip()
|
||||||
|
if learner and learner.current_name.strip() != input_name:
|
||||||
|
errors.append(f"手机号已属于学员“{learner.current_name}”,姓名不一致")
|
||||||
|
except ValueError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def upsert_learner(db: Session, row_data: dict[str, object]) -> Learner:
|
def template_headers(template: CertificateTemplateDefinition) -> list[str]:
|
||||||
phone = str(row_data[COL_PHONE]).strip()
|
return COMMON_HEADERS + [FIELD_COLUMNS[field.key] for field in template.fields if field.key != "learner_name"]
|
||||||
name = str(row_data[COL_NAME]).strip()
|
|
||||||
learner = db.query(Learner).filter(Learner.phone == phone).first()
|
|
||||||
if learner:
|
|
||||||
if learner.current_name != name:
|
|
||||||
db.add(LearnerNameHistory(learner_id=learner.id, name=name, source="import"))
|
|
||||||
learner.current_name = name
|
|
||||||
return learner
|
|
||||||
|
|
||||||
learner = Learner(phone=phone, current_name=name)
|
|
||||||
db.add(learner)
|
|
||||||
db.flush()
|
|
||||||
db.add(LearnerNameHistory(learner_id=learner.id, name=name, source="import"))
|
|
||||||
return learner
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_project(db: Session, project_code: str) -> ProjectCourse:
|
def required_headers(template: CertificateTemplateDefinition) -> list[str]:
|
||||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
|
return COMMON_HEADERS + [FIELD_COLUMNS[field.key] for field in template.fields if field.key != "learner_name" and field.required]
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Project code is inactive or missing: {project_code}")
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
def find_duplicate_certificate(
|
|
||||||
db: Session,
|
|
||||||
learner_id: int,
|
|
||||||
project: ProjectCourse,
|
|
||||||
course_start_date: date,
|
|
||||||
course_end_date: date,
|
|
||||||
issue_date: date,
|
|
||||||
template_code: str,
|
|
||||||
) -> Certificate | None:
|
|
||||||
return (
|
|
||||||
db.query(Certificate)
|
|
||||||
.filter(Certificate.learner_id == learner_id)
|
|
||||||
.filter(Certificate.project_code == project.code)
|
|
||||||
.filter(Certificate.certificate_name == project.default_certificate_name)
|
|
||||||
.filter(Certificate.course_name == project.default_course_name)
|
|
||||||
.filter(Certificate.stage_name == project.default_stage_name)
|
|
||||||
.filter(Certificate.course_start_date == course_start_date)
|
|
||||||
.filter(Certificate.course_end_date == course_end_date)
|
|
||||||
.filter(Certificate.issue_date == issue_date)
|
|
||||||
.filter(Certificate.template_code == template_code)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(db: Session, certificate_id: int, token_type: str) -> int:
|
|
||||||
raw_token = generate_public_token()
|
|
||||||
access_token = CertificateAccessToken(
|
|
||||||
certificate_id=certificate_id,
|
|
||||||
token_hash=hash_token(raw_token),
|
|
||||||
token_value=raw_token,
|
|
||||||
token_type=token_type,
|
|
||||||
)
|
|
||||||
db.add(access_token)
|
|
||||||
db.flush()
|
|
||||||
return access_token.id
|
|
||||||
|
|
||||||
|
|
||||||
def optional_text(value: object) -> str | None:
|
def optional_text(value: object) -> str | None:
|
||||||
@@ -381,6 +332,10 @@ def parse_issue_date(value: object) -> date:
|
|||||||
return parse_date(value, COL_ISSUE_DATE)
|
return parse_date(value, COL_ISSUE_DATE)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_optional_date(value: object, field_name: str) -> date | None:
|
||||||
|
return parse_date(value, field_name) if value not in (None, "") else None
|
||||||
|
|
||||||
|
|
||||||
def parse_date(value: object, field_name: str = "日期") -> date:
|
def parse_date(value: object, field_name: str = "日期") -> date:
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
return value.date()
|
return value.date()
|
||||||
@@ -392,22 +347,22 @@ def parse_date(value: object, field_name: str = "日期") -> date:
|
|||||||
return datetime.strptime(text, fmt).date()
|
return datetime.strptime(text, fmt).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name}格式错误,请使用YYYY-MM-DD:{text}")
|
raise ValueError(f"{field_name}格式错误,请使用YYYY-MM-DD:{text}")
|
||||||
|
|
||||||
|
|
||||||
def date_is_valid(value: object) -> bool:
|
def date_is_valid(value: object) -> bool:
|
||||||
try:
|
try:
|
||||||
parse_issue_date(value)
|
parse_date(value)
|
||||||
return True
|
return True
|
||||||
except HTTPException:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def write_error_report(db: Session, batch_id: int) -> Path:
|
def write_error_report(db: Session, batch_id: int) -> Path:
|
||||||
workbook = Workbook()
|
workbook = Workbook()
|
||||||
sheet = workbook.active
|
sheet = workbook.active
|
||||||
sheet.title = "\u9519\u8bef\u62a5\u544a"
|
sheet.title = "错误报告"
|
||||||
sheet.append(["\u884c\u53f7", "\u9519\u8bef\u539f\u56e0", "\u539f\u59cb\u6570\u636e"])
|
sheet.append(["行号", "错误原因", "原始数据"])
|
||||||
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch_id, ImportBatchRow.status == "failed").all()
|
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch_id, ImportBatchRow.status == "failed").all()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
sheet.append([row.row_no, row.error_message, row.raw_json])
|
sheet.append([row.row_no, row.error_message, row.raw_json])
|
||||||
@@ -416,60 +371,61 @@ def write_error_report(db: Session, batch_id: int) -> Path:
|
|||||||
return report_path
|
return report_path
|
||||||
|
|
||||||
|
|
||||||
def _format_template_sheet(sheet) -> None:
|
def _format_template_sheet(sheet, headers: list[str], template: CertificateTemplateDefinition) -> None:
|
||||||
header_fill = PatternFill("solid", fgColor="208A87")
|
header_fill = PatternFill("solid", fgColor="208A87")
|
||||||
for cell in sheet[1]:
|
for cell in sheet[1]:
|
||||||
cell.fill = header_fill
|
cell.fill = header_fill
|
||||||
cell.font = Font(color="FFFFFF", bold=True)
|
cell.font = Font(color="FFFFFF", bold=True)
|
||||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
sheet.freeze_panes = "A2"
|
sheet.freeze_panes = "A2"
|
||||||
sheet.auto_filter.ref = f"A1:{sheet.cell(1, len(TEMPLATE_HEADERS)).coordinate}"
|
sheet.auto_filter.ref = f"A1:{sheet.cell(1, len(headers)).coordinate}"
|
||||||
widths = [16, 18, 16, 18, 18, 18]
|
widths = {COL_NAME: 16, COL_PHONE: 18, COL_PROJECT: 16, COL_COURSE_NAME: 28, COL_STAGE_NAME: 18, COL_COURSE_START_DATE: 18, COL_COURSE_END_DATE: 18, COL_ISSUE_DATE: 18}
|
||||||
for index, width in enumerate(widths, start=1):
|
|
||||||
sheet.column_dimensions[sheet.cell(1, index).column_letter].width = width
|
|
||||||
sheet.column_dimensions["B"].number_format = "@"
|
sheet.column_dimensions["B"].number_format = "@"
|
||||||
for column_index in [4, 5, 6]:
|
for index, header in enumerate(headers, start=1):
|
||||||
column_letter = sheet.cell(1, column_index).column_letter
|
column_letter = sheet.cell(1, index).column_letter
|
||||||
sheet.column_dimensions[column_letter].number_format = "yyyy-mm-dd"
|
sheet.column_dimensions[column_letter].width = widths[header]
|
||||||
validation = DataValidation(type="date", operator="between", formula1="DATE(2000,1,1)", formula2="DATE(2100,12,31)", allow_blank=False)
|
field = next((item for item in template.fields if item.label == header), None)
|
||||||
validation.promptTitle = "日期格式"
|
required = header in required_headers(template)
|
||||||
validation.prompt = "请按 YYYY-MM-DD 填写,例如 2026-06-01"
|
sheet.cell(1, index).comment = Comment(f"{'必填' if required else '选填'}。{field.description if field else '用于识别和归档数据'}", "证书管理系统")
|
||||||
validation.errorTitle = "日期格式错误"
|
if field and field.field_type == "date":
|
||||||
validation.error = "请填写 2000-01-01 至 2100-12-31 之间的有效日期"
|
sheet.column_dimensions[column_letter].number_format = "yyyy-mm-dd"
|
||||||
validation.errorStyle = "stop"
|
validation = DataValidation(type="date", operator="between", formula1="DATE(2000,1,1)", formula2="DATE(2100,12,31)", allow_blank=not field.required)
|
||||||
validation.showInputMessage = True
|
validation.promptTitle = "日期格式"
|
||||||
validation.showErrorMessage = True
|
validation.prompt = f"请按 YYYY-MM-DD 填写,例如 {field.example}"
|
||||||
sheet.add_data_validation(validation)
|
validation.errorTitle = "日期格式错误"
|
||||||
validation.add(f"{column_letter}2:{column_letter}5000")
|
validation.error = "请填写有效日期"
|
||||||
sheet.cell(1, column_index).comment = Comment("必填。请使用 YYYY-MM-DD 格式,例如 2026-06-01。", "证书管理系统")
|
validation.errorStyle = "stop"
|
||||||
|
validation.showInputMessage = True
|
||||||
|
validation.showErrorMessage = True
|
||||||
|
sheet.add_data_validation(validation)
|
||||||
|
validation.add(f"{column_letter}2:{column_letter}5000")
|
||||||
|
|
||||||
|
|
||||||
def _add_template_instructions(workbook: Workbook) -> None:
|
def _add_template_instructions(workbook: Workbook, template: CertificateTemplateDefinition) -> None:
|
||||||
sheet = workbook.create_sheet("填写说明")
|
sheet = workbook.create_sheet("填写说明")
|
||||||
sheet.append(["字段", "是否必填", "格式或示例", "填写说明"])
|
sheet.append(["字段", "是否必填", "格式或示例", "填写说明"])
|
||||||
rows = [
|
instructions = [
|
||||||
(COL_NAME, "是", "张三", "填写学员真实姓名"),
|
(COL_NAME, True, "张三", "填写学员真实姓名;与手机号共同确认学员身份"),
|
||||||
(COL_PHONE, "是", "13800000000", "建议将单元格设为文本,避免手机号格式变化"),
|
(COL_PHONE, True, "13800000000", "不存在时自动创建学员;已存在时姓名必须一致"),
|
||||||
(COL_PROJECT, "是", "DBY", "填写系统中已启用的项目代码"),
|
(COL_PROJECT, True, "DBY", "填写系统中已启用的项目代码"),
|
||||||
(COL_COURSE_START_DATE, "是", "2026-06-01", "课程实际开始日期,必须使用 YYYY-MM-DD"),
|
|
||||||
(COL_COURSE_END_DATE, "是", "2026-06-30", "不得早于课程开始日期,必须使用 YYYY-MM-DD"),
|
|
||||||
(COL_ISSUE_DATE, "是", "2026-07-05", "证书签发日期,必须使用 YYYY-MM-DD"),
|
|
||||||
]
|
]
|
||||||
for row in rows:
|
instructions.extend((field.label, field.required, field.example, field.description) for field in template.fields if field.key != "learner_name")
|
||||||
sheet.append(row)
|
for label, required, example, description in instructions:
|
||||||
sheet.append([])
|
sheet.append([label, "是" if required else "否", example, description])
|
||||||
sheet.append(["重要提示", "请在“证书导入模板”工作表填写正式数据,不要修改第一行列名。日期统一填写为 YYYY-MM-DD,例如 2026-06-01。"])
|
note_row = len(instructions) + 3
|
||||||
sheet.merge_cells(start_row=9, start_column=2, end_row=9, end_column=4)
|
sheet.cell(note_row, 1, "重要提示")
|
||||||
|
sheet.cell(note_row, 2, f"本文件仅适用于“{template.name}”。不要修改第一行列名;日期统一使用 YYYY-MM-DD。")
|
||||||
|
sheet.merge_cells(start_row=note_row, start_column=2, end_row=note_row, end_column=4)
|
||||||
for cell in sheet[1]:
|
for cell in sheet[1]:
|
||||||
cell.fill = PatternFill("solid", fgColor="208A87")
|
cell.fill = PatternFill("solid", fgColor="208A87")
|
||||||
cell.font = Font(color="FFFFFF", bold=True)
|
cell.font = Font(color="FFFFFF", bold=True)
|
||||||
cell.alignment = Alignment(horizontal="center")
|
cell.alignment = Alignment(horizontal="center")
|
||||||
sheet["A9"].font = Font(color="C00000", bold=True)
|
sheet.cell(note_row, 1).font = Font(color="C00000", bold=True)
|
||||||
sheet["B9"].font = Font(color="C00000", bold=True)
|
sheet.cell(note_row, 2).font = Font(color="C00000", bold=True)
|
||||||
sheet["B9"].alignment = Alignment(wrap_text=True, vertical="center")
|
sheet.cell(note_row, 2).alignment = Alignment(wrap_text=True, vertical="center")
|
||||||
sheet.row_dimensions[9].height = 34
|
sheet.row_dimensions[note_row].height = 34
|
||||||
sheet.column_dimensions["A"].width = 20
|
sheet.column_dimensions["A"].width = 20
|
||||||
sheet.column_dimensions["B"].width = 16
|
sheet.column_dimensions["B"].width = 16
|
||||||
sheet.column_dimensions["C"].width = 22
|
sheet.column_dimensions["C"].width = 24
|
||||||
sheet.column_dimensions["D"].width = 54
|
sheet.column_dimensions["D"].width = 58
|
||||||
sheet.freeze_panes = "A2"
|
sheet.freeze_panes = "A2"
|
||||||
|
|||||||
@@ -1,27 +1,47 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from io import BytesIO
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from openpyxl.comments import Comment
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import require_roles
|
from app.api.deps import require_roles
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models import AdminUser, Learner, LearnerNameHistory
|
from app.models import AdminUser, Certificate, Learner, LearnerNameHistory
|
||||||
from app.schemas.learner import LearnerCreate, LearnerOut, LearnerUpdate
|
from app.schemas.learner import LearnerCreate, LearnerImportResult, LearnerOut, LearnerUpdate
|
||||||
|
from app.services.learner_identity import LearnerIdentityConflict, normalize_name, normalize_phone, resolve_learner
|
||||||
from app.services.logs import diff_values, log_action, mask_phone
|
from app.services.logs import diff_values, log_action, mask_phone
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
LEARNER_IMPORT_HEADERS = ["姓名", "手机号", "学员编号", "备注"]
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[LearnerOut])
|
@router.get("", response_model=list[LearnerOut])
|
||||||
def list_learners(
|
def list_learners(
|
||||||
keyword: str | None = Query(default=None),
|
keyword: str | None = Query(default=None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||||
) -> list[Learner]:
|
) -> list[dict[str, object]]:
|
||||||
query = db.query(Learner).filter(Learner.status != "deleted").order_by(Learner.id.desc())
|
count_subquery = (
|
||||||
|
db.query(Certificate.learner_id.label("learner_id"), func.count(Certificate.id).label("certificate_count"))
|
||||||
|
.group_by(Certificate.learner_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
query = (
|
||||||
|
db.query(Learner, func.coalesce(count_subquery.c.certificate_count, 0))
|
||||||
|
.outerjoin(count_subquery, count_subquery.c.learner_id == Learner.id)
|
||||||
|
.filter(Learner.status != "deleted")
|
||||||
|
.order_by(Learner.id.desc())
|
||||||
|
)
|
||||||
if keyword:
|
if keyword:
|
||||||
like = f"%{keyword}%"
|
like = f"%{keyword}%"
|
||||||
query = query.filter(or_(Learner.current_name.like(like), Learner.phone.like(like), Learner.student_no.like(like)))
|
query = query.filter(or_(Learner.current_name.like(like), Learner.phone.like(like), Learner.student_no.like(like)))
|
||||||
return query.limit(100).all()
|
return [{**LearnerOut.model_validate(learner).model_dump(), "certificate_count": certificate_count} for learner, certificate_count in query.limit(100).all()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=LearnerOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=LearnerOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -30,32 +50,132 @@ def create_learner(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||||
) -> Learner:
|
) -> Learner:
|
||||||
existing = db.query(Learner).filter(Learner.phone == payload.phone).first()
|
try:
|
||||||
if existing:
|
learner, created = resolve_learner(db, payload.current_name, payload.phone, source="manual", create_if_missing=True)
|
||||||
if existing.current_name != payload.current_name:
|
except (ValueError, LearnerIdentityConflict) as exc:
|
||||||
db.add(LearnerNameHistory(learner_id=existing.id, name=payload.current_name, source="manual"))
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
existing.current_name = payload.current_name
|
assert learner is not None
|
||||||
existing.student_no = payload.student_no
|
learner.student_no = payload.student_no
|
||||||
existing.remark = payload.remark
|
learner.remark = payload.remark
|
||||||
log_action(db, admin, "update_learner", "learner", existing.id, {"name": existing.current_name, "phone": mask_phone(existing.phone)})
|
action = "create_learner" if created else "update_learner"
|
||||||
db.commit()
|
log_action(db, admin, action, "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "status": learner.status})
|
||||||
db.refresh(existing)
|
|
||||||
return existing
|
|
||||||
learner = Learner(
|
|
||||||
phone=payload.phone,
|
|
||||||
current_name=payload.current_name,
|
|
||||||
student_no=payload.student_no,
|
|
||||||
remark=payload.remark,
|
|
||||||
)
|
|
||||||
db.add(learner)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(learner)
|
db.refresh(learner)
|
||||||
db.add(LearnerNameHistory(learner_id=learner.id, name=learner.current_name, source="manual"))
|
|
||||||
log_action(db, admin, "create_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "status": learner.status})
|
|
||||||
db.commit()
|
|
||||||
return learner
|
return learner
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/import-template")
|
||||||
|
def download_learner_import_template(
|
||||||
|
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
workbook = build_learner_import_template()
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
stream.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="learner-import-template.xlsx"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=LearnerImportResult)
|
||||||
|
def import_learners(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if not file.filename or not file.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 .xlsx 文件")
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(file.file, read_only=True, data_only=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件无法读取") from exc
|
||||||
|
sheet = workbook.active
|
||||||
|
first_row = next(sheet.iter_rows(min_row=1, max_row=1), None)
|
||||||
|
headers = [cell.value for cell in first_row] if first_row else []
|
||||||
|
header_index = {name: idx for idx, name in enumerate(headers)}
|
||||||
|
missing = [name for name in LEARNER_IMPORT_HEADERS[:2] if name not in header_index]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"缺少必填列:{'、'.join(missing)}")
|
||||||
|
|
||||||
|
result: dict[str, object] = {"total_rows": 0, "created_rows": 0, "updated_rows": 0, "unchanged_rows": 0, "failed_rows": 0, "errors": []}
|
||||||
|
seen_phones: dict[str, str] = {}
|
||||||
|
for row_no, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
|
||||||
|
if not any(row):
|
||||||
|
continue
|
||||||
|
result["total_rows"] += 1
|
||||||
|
values = {name: row[index] if index < len(row) else None for name, index in header_index.items()}
|
||||||
|
try:
|
||||||
|
name = normalize_name(str(values.get("姓名") or ""))
|
||||||
|
phone = normalize_phone(str(values.get("手机号") or ""))
|
||||||
|
if not name:
|
||||||
|
raise ValueError("姓名不能为空")
|
||||||
|
if phone in seen_phones and seen_phones[phone] != name:
|
||||||
|
raise LearnerIdentityConflict(f"文件内手机号重复且姓名不一致:{seen_phones[phone]} / {name}")
|
||||||
|
seen_phones[phone] = name
|
||||||
|
learner, created = resolve_learner(db, name, phone, source="learner_import", create_if_missing=True)
|
||||||
|
assert learner is not None
|
||||||
|
changed = False
|
||||||
|
student_no = _optional_cell(values.get("学员编号"))
|
||||||
|
remark = _optional_cell(values.get("备注"))
|
||||||
|
if student_no is not None and learner.student_no != student_no:
|
||||||
|
learner.student_no = student_no
|
||||||
|
changed = True
|
||||||
|
if remark is not None and learner.remark != remark:
|
||||||
|
learner.remark = remark
|
||||||
|
changed = True
|
||||||
|
if created:
|
||||||
|
result["created_rows"] += 1
|
||||||
|
elif changed:
|
||||||
|
result["updated_rows"] += 1
|
||||||
|
else:
|
||||||
|
result["unchanged_rows"] += 1
|
||||||
|
except (ValueError, LearnerIdentityConflict) as exc:
|
||||||
|
result["failed_rows"] += 1
|
||||||
|
result["errors"].append({"row_no": row_no, "message": str(exc)})
|
||||||
|
|
||||||
|
log_action(db, admin, "import_learners", "learner", detail={key: value for key, value in result.items() if key != "errors"})
|
||||||
|
db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_learner_import_template() -> Workbook:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "学员导入模板"
|
||||||
|
sheet.append(LEARNER_IMPORT_HEADERS)
|
||||||
|
examples = {
|
||||||
|
"姓名": ("张三", "必填。填写学员真实姓名"),
|
||||||
|
"手机号": ("13800000000", "必填。11位手机号;系统中已存在时姓名必须一致"),
|
||||||
|
"学员编号": ("HY20260001", "选填。内部管理编号"),
|
||||||
|
"备注": ("2026年大本营学员", "选填。内部备注"),
|
||||||
|
}
|
||||||
|
for index, header in enumerate(LEARNER_IMPORT_HEADERS, start=1):
|
||||||
|
cell = sheet.cell(1, index)
|
||||||
|
cell.fill = PatternFill("solid", fgColor="208A87")
|
||||||
|
cell.font = Font(color="FFFFFF", bold=True)
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
cell.comment = Comment(examples[header][1], "证书管理系统")
|
||||||
|
sheet.column_dimensions[cell.column_letter].width = 18 if header != "备注" else 32
|
||||||
|
sheet.column_dimensions["B"].number_format = "@"
|
||||||
|
instruction = workbook.create_sheet("填写说明")
|
||||||
|
instruction.append(["字段", "是否必填", "示例", "填写说明"])
|
||||||
|
for header in LEARNER_IMPORT_HEADERS:
|
||||||
|
instruction.append([header, "是" if header in LEARNER_IMPORT_HEADERS[:2] else "否", examples[header][0], examples[header][1]])
|
||||||
|
instruction.column_dimensions["A"].width = 18
|
||||||
|
instruction.column_dimensions["B"].width = 14
|
||||||
|
instruction.column_dimensions["C"].width = 22
|
||||||
|
instruction.column_dimensions["D"].width = 52
|
||||||
|
sheet.freeze_panes = "A2"
|
||||||
|
return workbook
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_cell(value: object) -> str | None:
|
||||||
|
text = str(value).strip() if value is not None else ""
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{learner_id}", response_model=LearnerOut)
|
@router.get("/{learner_id}", response_model=LearnerOut)
|
||||||
def get_learner(
|
def get_learner(
|
||||||
learner_id: int,
|
learner_id: int,
|
||||||
@@ -78,7 +198,12 @@ def update_learner(
|
|||||||
learner = db.get(Learner, learner_id)
|
learner = db.get(Learner, learner_id)
|
||||||
if not learner:
|
if not learner:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
||||||
duplicate = db.query(Learner).filter(Learner.phone == payload.phone, Learner.id != learner_id).first()
|
try:
|
||||||
|
normalized_phone = normalize_phone(payload.phone)
|
||||||
|
normalized_name = normalize_name(payload.current_name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
duplicate = db.query(Learner).filter(Learner.phone == normalized_phone, Learner.id != learner_id).first()
|
||||||
if duplicate:
|
if duplicate:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Phone already exists")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Phone already exists")
|
||||||
before = {
|
before = {
|
||||||
@@ -88,10 +213,10 @@ def update_learner(
|
|||||||
"status": learner.status,
|
"status": learner.status,
|
||||||
"remark": learner.remark,
|
"remark": learner.remark,
|
||||||
}
|
}
|
||||||
if learner.current_name != payload.current_name:
|
if learner.current_name != normalized_name:
|
||||||
db.add(LearnerNameHistory(learner_id=learner.id, name=payload.current_name, source="manual"))
|
db.add(LearnerNameHistory(learner_id=learner.id, name=normalized_name, source="manual"))
|
||||||
learner.phone = payload.phone
|
learner.phone = normalized_phone
|
||||||
learner.current_name = payload.current_name
|
learner.current_name = normalized_name
|
||||||
learner.student_no = payload.student_no
|
learner.student_no = payload.student_no
|
||||||
learner.status = payload.status
|
learner.status = payload.status
|
||||||
learner.remark = payload.remark
|
learner.remark = payload.remark
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from pydantic import BaseModel, Field, ValidationInfo, field_validator
|
|||||||
|
|
||||||
|
|
||||||
class CertificateCreate(BaseModel):
|
class CertificateCreate(BaseModel):
|
||||||
learner_id: int
|
learner_name: str = Field(min_length=1, max_length=64)
|
||||||
|
learner_phone: str = Field(min_length=6, max_length=32)
|
||||||
project_code: str = Field(min_length=2, max_length=16)
|
project_code: str = Field(min_length=2, max_length=16)
|
||||||
certificate_name: str | None = Field(default=None, max_length=128)
|
certificate_name: str | None = Field(default=None, max_length=128)
|
||||||
class_name: str | None = Field(default=None, max_length=128)
|
class_name: str | None = Field(default=None, max_length=128)
|
||||||
@@ -21,6 +22,11 @@ class CertificateCreate(BaseModel):
|
|||||||
def normalize_project_code(cls, value: str) -> str:
|
def normalize_project_code(cls, value: str) -> str:
|
||||||
return value.strip().upper()
|
return value.strip().upper()
|
||||||
|
|
||||||
|
@field_validator("learner_name", "learner_phone")
|
||||||
|
@classmethod
|
||||||
|
def strip_identity(cls, value: str) -> str:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
@field_validator("course_end_date")
|
@field_validator("course_end_date")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_course_period(cls, value: date, info: ValidationInfo) -> date:
|
def validate_course_period(cls, value: date, info: ValidationInfo) -> date:
|
||||||
@@ -34,6 +40,7 @@ class CertificateOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
learner_id: int
|
learner_id: int
|
||||||
learner_name: str | None = None
|
learner_name: str | None = None
|
||||||
|
learner_phone: str | None = None
|
||||||
import_batch_id: int | None = None
|
import_batch_id: int | None = None
|
||||||
project_code: str
|
project_code: str
|
||||||
certificate_no: str
|
certificate_no: str
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateTemplateFieldOut(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
field_type: str
|
||||||
|
required: bool
|
||||||
|
example: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
class CertificateTemplateOut(BaseModel):
|
class CertificateTemplateOut(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
dynamic_fields: list[str]
|
dynamic_fields: list[str]
|
||||||
|
fields: list[CertificateTemplateFieldOut]
|
||||||
preview_url: str
|
preview_url: str
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -25,7 +25,22 @@ class LearnerOut(BaseModel):
|
|||||||
student_no: str | None
|
student_no: str | None
|
||||||
status: str
|
status: str
|
||||||
remark: str | None
|
remark: str | None
|
||||||
|
certificate_count: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class LearnerImportError(BaseModel):
|
||||||
|
row_no: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class LearnerImportResult(BaseModel):
|
||||||
|
total_rows: int
|
||||||
|
created_rows: int
|
||||||
|
updated_rows: int
|
||||||
|
unchanged_rows: int
|
||||||
|
failed_rows: int
|
||||||
|
errors: list[LearnerImportError]
|
||||||
|
|||||||
135
backend/app/services/certificate_issuance.py
Normal file
135
backend/app/services/certificate_issuance.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.security import generate_public_token, hash_token
|
||||||
|
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||||
|
from app.services.certificate_number import build_certificate_no
|
||||||
|
from app.services.certificate_templates import certificate_field_keys, get_certificate_template
|
||||||
|
from app.services.learner_identity import resolve_learner
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateCertificate(ValueError):
|
||||||
|
def __init__(self, certificate: Certificate):
|
||||||
|
self.certificate = certificate
|
||||||
|
super().__init__(f"已存在相同证书:{certificate.certificate_no}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CertificateIssueData:
|
||||||
|
learner_name: str
|
||||||
|
learner_phone: str
|
||||||
|
project_code: str
|
||||||
|
template_code: str
|
||||||
|
issue_date: date
|
||||||
|
course_start_date: date | None = None
|
||||||
|
course_end_date: date | None = None
|
||||||
|
course_name: str | None = None
|
||||||
|
stage_name: str | None = None
|
||||||
|
class_name: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
import_batch_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def issue_certificate(db: Session, data: CertificateIssueData, *, source: str) -> tuple[Certificate, Learner, bool]:
|
||||||
|
template = get_certificate_template(data.template_code)
|
||||||
|
project_code = data.project_code.strip().upper()
|
||||||
|
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
|
||||||
|
if not project:
|
||||||
|
raise ValueError(f"项目代码不存在或已停用:{project_code}")
|
||||||
|
|
||||||
|
values = _template_values(data, project)
|
||||||
|
_validate_template_values(template, values)
|
||||||
|
learner, learner_created = resolve_learner(
|
||||||
|
db,
|
||||||
|
data.learner_name,
|
||||||
|
data.learner_phone,
|
||||||
|
source=source,
|
||||||
|
create_if_missing=True,
|
||||||
|
)
|
||||||
|
assert learner is not None
|
||||||
|
duplicate = find_duplicate_certificate(db, learner.id, project_code, template.code, values)
|
||||||
|
if duplicate:
|
||||||
|
raise DuplicateCertificate(duplicate)
|
||||||
|
|
||||||
|
certificate = Certificate(
|
||||||
|
learner_id=learner.id,
|
||||||
|
import_batch_id=data.import_batch_id,
|
||||||
|
project_code=project_code,
|
||||||
|
certificate_no="PENDING",
|
||||||
|
certificate_name=project.default_certificate_name or project.name,
|
||||||
|
class_name=data.class_name,
|
||||||
|
course_name=values["course_name"],
|
||||||
|
stage_name=values["stage_name"],
|
||||||
|
course_start_date=values["course_start_date"],
|
||||||
|
course_end_date=values["course_end_date"],
|
||||||
|
issue_date=data.issue_date,
|
||||||
|
template_code=template.code,
|
||||||
|
remark=data.remark,
|
||||||
|
)
|
||||||
|
db.add(certificate)
|
||||||
|
db.flush()
|
||||||
|
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
|
||||||
|
certificate.public_token_id = _create_access_token(db, certificate.id, "public_link")
|
||||||
|
certificate.qr_token_id = _create_access_token(db, certificate.id, "qr_verify")
|
||||||
|
return certificate, learner, learner_created
|
||||||
|
|
||||||
|
|
||||||
|
def find_duplicate_certificate(
|
||||||
|
db: Session,
|
||||||
|
learner_id: int,
|
||||||
|
project_code: str,
|
||||||
|
template_code: str,
|
||||||
|
values: dict[str, object],
|
||||||
|
) -> Certificate | None:
|
||||||
|
return (
|
||||||
|
db.query(Certificate)
|
||||||
|
.filter(Certificate.learner_id == learner_id)
|
||||||
|
.filter(Certificate.project_code == project_code)
|
||||||
|
.filter(Certificate.template_code == template_code)
|
||||||
|
.filter(Certificate.course_name == values["course_name"])
|
||||||
|
.filter(Certificate.stage_name == values["stage_name"])
|
||||||
|
.filter(Certificate.course_start_date == values["course_start_date"])
|
||||||
|
.filter(Certificate.course_end_date == values["course_end_date"])
|
||||||
|
.filter(Certificate.issue_date == values["issue_date"])
|
||||||
|
.filter(Certificate.status == "valid")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_values(data: CertificateIssueData, project: ProjectCourse) -> dict[str, object]:
|
||||||
|
template = get_certificate_template(data.template_code)
|
||||||
|
keys = certificate_field_keys(template)
|
||||||
|
return {
|
||||||
|
"course_name": (data.course_name or project.default_course_name) if "course_name" in keys else None,
|
||||||
|
"stage_name": (data.stage_name or project.default_stage_name) if "stage_name" in keys else None,
|
||||||
|
"course_start_date": data.course_start_date if "course_start_date" in keys else None,
|
||||||
|
"course_end_date": data.course_end_date if "course_end_date" in keys else None,
|
||||||
|
"issue_date": data.issue_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_template_values(template, values: dict[str, object]) -> None:
|
||||||
|
for field in template.fields:
|
||||||
|
if field.key == "learner_name":
|
||||||
|
continue
|
||||||
|
if field.required and not values.get(field.key):
|
||||||
|
raise ValueError(f"{template.name}需要填写{field.label}")
|
||||||
|
start_date = values.get("course_start_date")
|
||||||
|
end_date = values.get("course_end_date")
|
||||||
|
if start_date and end_date and end_date < start_date:
|
||||||
|
raise ValueError("课程结束日期不能早于课程开始日期")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_access_token(db: Session, certificate_id: int, token_type: str) -> int:
|
||||||
|
raw_token = generate_public_token()
|
||||||
|
token = CertificateAccessToken(
|
||||||
|
certificate_id=certificate_id,
|
||||||
|
token_hash=hash_token(raw_token),
|
||||||
|
token_value=raw_token,
|
||||||
|
token_type=token_type,
|
||||||
|
)
|
||||||
|
db.add(token)
|
||||||
|
db.flush()
|
||||||
|
return token.id
|
||||||
@@ -2,15 +2,39 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CertificateFieldDefinition:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
field_type: str
|
||||||
|
required: bool
|
||||||
|
example: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"label": self.label,
|
||||||
|
"field_type": self.field_type,
|
||||||
|
"required": self.required,
|
||||||
|
"example": self.example,
|
||||||
|
"description": self.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CertificateTemplateDefinition:
|
class CertificateTemplateDefinition:
|
||||||
code: str
|
code: str
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
asset_filename: str
|
asset_filename: str
|
||||||
dynamic_fields: tuple[str, ...]
|
fields: tuple[CertificateFieldDefinition, ...]
|
||||||
status: str = "active"
|
status: str = "active"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dynamic_fields(self) -> tuple[str, ...]:
|
||||||
|
return tuple(field.label for field in self.fields)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def asset_path(self) -> Path:
|
def asset_path(self) -> Path:
|
||||||
return Path(__file__).resolve().parents[1] / "assets" / self.asset_filename
|
return Path(__file__).resolve().parents[1] / "assets" / self.asset_filename
|
||||||
@@ -20,7 +44,8 @@ class CertificateTemplateDefinition:
|
|||||||
"code": self.code,
|
"code": self.code,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"dynamic_fields": list(self.dynamic_fields),
|
"dynamic_fields": [field.label for field in self.fields],
|
||||||
|
"fields": [field.to_dict() for field in self.fields],
|
||||||
"preview_url": f"/api/admin/certificate-templates/{self.code}/preview",
|
"preview_url": f"/api/admin/certificate-templates/{self.code}/preview",
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
}
|
}
|
||||||
@@ -35,14 +60,26 @@ CERTIFICATE_TEMPLATES = {
|
|||||||
name="经典结业证书",
|
name="经典结业证书",
|
||||||
description="通用课程结业证书,正文包含课程名称、阶段、课程时间和发证日期。",
|
description="通用课程结业证书,正文包含课程名称、阶段、课程时间和发证日期。",
|
||||||
asset_filename="certificate-template.png",
|
asset_filename="certificate-template.png",
|
||||||
dynamic_fields=("姓名", "课程名称", "阶段名称", "课程开始日期", "课程结束日期", "发证日期"),
|
fields=(
|
||||||
|
CertificateFieldDefinition("learner_name", "姓名", "text", True, "张三", "证书持有人真实姓名"),
|
||||||
|
CertificateFieldDefinition("course_name", "课程名称", "text", True, "智慧赋能疗愈师", "证书正文中的课程名称"),
|
||||||
|
CertificateFieldDefinition("stage_name", "阶段名称", "text", True, "初级", "证书正文中的课程阶段"),
|
||||||
|
CertificateFieldDefinition("course_start_date", "课程开始日期", "date", True, "2026-06-01", "课程实际开始日期"),
|
||||||
|
CertificateFieldDefinition("course_end_date", "课程结束日期", "date", True, "2026-06-30", "课程实际结束日期"),
|
||||||
|
CertificateFieldDefinition("issue_date", "发证日期", "date", True, "2026-07-05", "证书签发日期"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
PRACTICE_CAMP_TEMPLATE_CODE: CertificateTemplateDefinition(
|
PRACTICE_CAMP_TEMPLATE_CODE: CertificateTemplateDefinition(
|
||||||
code=PRACTICE_CAMP_TEMPLATE_CODE,
|
code=PRACTICE_CAMP_TEMPLATE_CODE,
|
||||||
name="实修大本营结业证书",
|
name="实修大本营结业证书",
|
||||||
description="人本智慧五个月线上实修大本营专用版,只填写姓名、课程开始日期、课程结束日期和发证日期。",
|
description="人本智慧五个月线上实修大本营专用版,只填写姓名、课程开始日期、课程结束日期和发证日期。",
|
||||||
asset_filename="certificate-template-practice-camp.png",
|
asset_filename="certificate-template-practice-camp.png",
|
||||||
dynamic_fields=("姓名", "课程开始日期", "课程结束日期", "发证日期"),
|
fields=(
|
||||||
|
CertificateFieldDefinition("learner_name", "姓名", "text", True, "张三", "证书持有人真实姓名"),
|
||||||
|
CertificateFieldDefinition("course_start_date", "课程开始日期", "date", True, "2026-03-01", "课程实际开始日期"),
|
||||||
|
CertificateFieldDefinition("course_end_date", "课程结束日期", "date", True, "2026-08-31", "课程实际结束日期"),
|
||||||
|
CertificateFieldDefinition("issue_date", "发证日期", "date", True, "2026-09-05", "证书签发日期"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,3 +96,7 @@ def get_certificate_template(code: str | None) -> CertificateTemplateDefinition:
|
|||||||
if not template.asset_path.exists():
|
if not template.asset_path.exists():
|
||||||
raise FileNotFoundError(f"证书模板图片不存在:{template.asset_filename}")
|
raise FileNotFoundError(f"证书模板图片不存在:{template.asset_filename}")
|
||||||
return template
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def certificate_field_keys(template: CertificateTemplateDefinition) -> set[str]:
|
||||||
|
return {field.key for field in template.fields}
|
||||||
|
|||||||
57
backend/app/services/learner_identity.py
Normal file
57
backend/app/services/learner_identity.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Learner, LearnerNameHistory
|
||||||
|
|
||||||
|
|
||||||
|
class LearnerIdentityConflict(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(value: str) -> str:
|
||||||
|
return " ".join(value.strip().split())
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_phone(value: str) -> str:
|
||||||
|
phone = re.sub(r"[\s-]", "", value.strip())
|
||||||
|
if phone.startswith("+86"):
|
||||||
|
phone = phone[3:]
|
||||||
|
elif phone.startswith("0086"):
|
||||||
|
phone = phone[4:]
|
||||||
|
if not re.fullmatch(r"1\d{10}", phone):
|
||||||
|
raise ValueError("手机号格式错误,请填写11位中国大陆手机号")
|
||||||
|
return phone
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_learner(
|
||||||
|
db: Session,
|
||||||
|
name: str,
|
||||||
|
phone: str,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
create_if_missing: bool = True,
|
||||||
|
) -> tuple[Learner | None, bool]:
|
||||||
|
normalized_name = normalize_name(name)
|
||||||
|
normalized_phone = normalize_phone(phone)
|
||||||
|
if not normalized_name:
|
||||||
|
raise ValueError("姓名不能为空")
|
||||||
|
|
||||||
|
learner = db.query(Learner).filter(Learner.phone == normalized_phone, Learner.status != "deleted").first()
|
||||||
|
if learner:
|
||||||
|
if normalize_name(learner.current_name) != normalized_name:
|
||||||
|
raise LearnerIdentityConflict(
|
||||||
|
f"手机号 {normalized_phone} 已属于学员“{learner.current_name}”,与本次姓名“{normalized_name}”不一致"
|
||||||
|
)
|
||||||
|
if learner.status != "active":
|
||||||
|
raise LearnerIdentityConflict(f"学员“{normalized_name}”当前已停用,请先在学员管理中启用")
|
||||||
|
return learner, False
|
||||||
|
|
||||||
|
if not create_if_missing:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
learner = Learner(phone=normalized_phone, current_name=normalized_name)
|
||||||
|
db.add(learner)
|
||||||
|
db.flush()
|
||||||
|
db.add(LearnerNameHistory(learner_id=learner.id, name=normalized_name, source=source))
|
||||||
|
return learner, True
|
||||||
62
backend/docs/learner-certificate-domain.md
Normal file
62
backend/docs/learner-certificate-domain.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 学员与证书业务模型
|
||||||
|
|
||||||
|
## 1. 核心关系
|
||||||
|
|
||||||
|
- 学员是长期主体,证书是某一次学习成果的签发记录。
|
||||||
|
- 一个学员可以拥有多张证书,证书可以来自不同项目、不同课程、不同模板和不同时间段。
|
||||||
|
- 证书签发后保存当次课程、阶段、日期和模板代码,不依赖项目默认值继续变化。
|
||||||
|
- 项目只负责归档和提供默认值,不能代替证书模板定义字段。
|
||||||
|
|
||||||
|
## 2. 学员身份规则
|
||||||
|
|
||||||
|
- 系统使用标准化后的手机号定位学员,姓名用于一致性校验和业务确认。
|
||||||
|
- 输入手机号会去除空格、短横线及中国大陆区号,最终保存为 11 位手机号。
|
||||||
|
- 手机号不存在时,手工签发和证书批量导入都会自动创建学员。
|
||||||
|
- 手机号已存在且姓名一致时,复用原学员。
|
||||||
|
- 手机号已存在但姓名不一致时,必须报冲突,不能自动改名或把证书挂到错误学员。
|
||||||
|
- 确需更名时,应在学员管理中人工修改,并保留姓名历史。
|
||||||
|
|
||||||
|
## 3. 模板字段规则
|
||||||
|
|
||||||
|
`app/services/certificate_templates.py` 是模板字段的唯一规则源。每个字段定义:
|
||||||
|
|
||||||
|
- `key`:后端和前端使用的稳定字段键。
|
||||||
|
- `label`:页面和 Excel 使用的中文名称。
|
||||||
|
- `field_type`:文本或日期。
|
||||||
|
- `required`:该模板签发证书时是否必填。
|
||||||
|
- `example`:Excel 填写示例。
|
||||||
|
- `description`:后台提示和 Excel 填写说明。
|
||||||
|
|
||||||
|
新增模板时,应先在这里声明字段。手工创建表单、Excel 表头、Excel 校验和后端签发校验都会读取同一份定义。
|
||||||
|
|
||||||
|
## 4. 导入流程
|
||||||
|
|
||||||
|
### 证书导入
|
||||||
|
|
||||||
|
1. 先选择证书模板。
|
||||||
|
2. 下载该模板专用 Excel,不能跨模板混用。
|
||||||
|
3. 上传后只做校验,不立即写入正式证书。
|
||||||
|
4. 管理员确认后调用统一签发服务。
|
||||||
|
5. 学员不存在时自动创建;重复证书跳过;身份冲突或字段错误进入失败记录。
|
||||||
|
|
||||||
|
证书导入的公共业务列是姓名、手机号和项目代码。其余列由所选证书模板决定。
|
||||||
|
|
||||||
|
### 学员导入
|
||||||
|
|
||||||
|
- 姓名和手机号必填,学员编号和备注选填。
|
||||||
|
- 只维护学员主数据,不创建证书。
|
||||||
|
- 已有学员姓名手机号一致时可补充学员编号和备注。
|
||||||
|
- 冲突行单独返回,不修改已有学员姓名。
|
||||||
|
|
||||||
|
## 5. 签发入口
|
||||||
|
|
||||||
|
手工创建和证书导入统一调用 `app/services/certificate_issuance.py`,由该服务负责:
|
||||||
|
|
||||||
|
- 校验项目和模板;
|
||||||
|
- 解析或创建学员;
|
||||||
|
- 校验模板字段和课程日期;
|
||||||
|
- 判断重复证书;
|
||||||
|
- 创建证书编号;
|
||||||
|
- 创建公开链接和二维码令牌。
|
||||||
|
|
||||||
|
路由层只处理权限、HTTP 参数、事务提交和操作日志,不应再次复制签发规则。
|
||||||
109
backend/tests/test_certificate_issuance.py
Normal file
109
backend/tests/test_certificate_issuance.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.routes.admin_imports import COL_COURSE_NAME, COL_STAGE_NAME, build_import_template_workbook
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models import Certificate, Learner, ProjectCourse
|
||||||
|
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
|
||||||
|
from app.services.learner_identity import LearnerIdentityConflict, resolve_learner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db() -> Session:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
def add_project(db: Session) -> None:
|
||||||
|
db.add(
|
||||||
|
ProjectCourse(
|
||||||
|
code="DBY",
|
||||||
|
name="大本营",
|
||||||
|
default_certificate_name="培训结业证书",
|
||||||
|
default_course_name="智慧赋能疗愈师",
|
||||||
|
default_stage_name="初级",
|
||||||
|
default_template_code="classic",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_practice_template_has_only_its_required_certificate_columns():
|
||||||
|
sheet = build_import_template_workbook("practice-camp")["证书导入模板"]
|
||||||
|
headers = [cell.value for cell in sheet[1]]
|
||||||
|
|
||||||
|
assert headers == ["姓名", "手机号", "项目代码", "课程开始日期", "课程结束日期", "发证日期"]
|
||||||
|
assert COL_COURSE_NAME not in headers
|
||||||
|
assert COL_STAGE_NAME not in headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_learner_identity_reuses_exact_name_and_phone_but_rejects_name_conflict(db: Session):
|
||||||
|
learner, created = resolve_learner(db, "张三", "138-0000-0000", source="test")
|
||||||
|
reused, reused_created = resolve_learner(db, "张三", "+86 13800000000", source="test")
|
||||||
|
|
||||||
|
assert created is True
|
||||||
|
assert reused_created is False
|
||||||
|
assert reused.id == learner.id
|
||||||
|
with pytest.raises(LearnerIdentityConflict, match="姓名.*不一致"):
|
||||||
|
resolve_learner(db, "李四", "13800000000", source="test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_learner_can_hold_multiple_template_certificates(db: Session):
|
||||||
|
add_project(db)
|
||||||
|
common = dict(
|
||||||
|
learner_name="张三",
|
||||||
|
learner_phone="13800000000",
|
||||||
|
project_code="DBY",
|
||||||
|
course_start_date=date(2026, 3, 1),
|
||||||
|
course_end_date=date(2026, 8, 31),
|
||||||
|
)
|
||||||
|
classic, _, classic_created = issue_certificate(
|
||||||
|
db,
|
||||||
|
CertificateIssueData(
|
||||||
|
**common,
|
||||||
|
template_code="classic",
|
||||||
|
issue_date=date(2026, 9, 5),
|
||||||
|
course_name="智慧赋能疗愈师",
|
||||||
|
stage_name="初级",
|
||||||
|
),
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
practice, _, practice_created = issue_certificate(
|
||||||
|
db,
|
||||||
|
CertificateIssueData(
|
||||||
|
**common,
|
||||||
|
template_code="practice-camp",
|
||||||
|
issue_date=date(2026, 9, 6),
|
||||||
|
),
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert classic_created is True
|
||||||
|
assert practice_created is False
|
||||||
|
assert classic.learner_id == practice.learner_id
|
||||||
|
assert db.query(Learner).count() == 1
|
||||||
|
assert db.query(Certificate).count() == 2
|
||||||
|
assert practice.course_name is None
|
||||||
|
assert practice.stage_name is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_certificate_is_rejected(db: Session):
|
||||||
|
add_project(db)
|
||||||
|
data = CertificateIssueData(
|
||||||
|
learner_name="张三",
|
||||||
|
learner_phone="13800000000",
|
||||||
|
project_code="DBY",
|
||||||
|
template_code="practice-camp",
|
||||||
|
course_start_date=date(2026, 3, 1),
|
||||||
|
course_end_date=date(2026, 8, 31),
|
||||||
|
issue_date=date(2026, 9, 5),
|
||||||
|
)
|
||||||
|
issue_certificate(db, data, source="test")
|
||||||
|
|
||||||
|
with pytest.raises(DuplicateCertificate):
|
||||||
|
issue_certificate(db, data, source="test")
|
||||||
@@ -6,10 +6,12 @@ from pydantic import ValidationError
|
|||||||
from app.api.routes.admin_imports import (
|
from app.api.routes.admin_imports import (
|
||||||
COL_ISSUE_DATE,
|
COL_ISSUE_DATE,
|
||||||
COL_COURSE_END_DATE,
|
COL_COURSE_END_DATE,
|
||||||
|
COL_COURSE_NAME,
|
||||||
COL_COURSE_START_DATE,
|
COL_COURSE_START_DATE,
|
||||||
COL_NAME,
|
COL_NAME,
|
||||||
COL_PHONE,
|
COL_PHONE,
|
||||||
COL_PROJECT,
|
COL_PROJECT,
|
||||||
|
COL_STAGE_NAME,
|
||||||
TEMPLATE_HEADERS,
|
TEMPLATE_HEADERS,
|
||||||
build_import_template_workbook,
|
build_import_template_workbook,
|
||||||
date_is_valid,
|
date_is_valid,
|
||||||
@@ -33,7 +35,7 @@ def test_row_errors_require_project_code_to_exist():
|
|||||||
COL_COURSE_END_DATE: "2026-05-31",
|
COL_COURSE_END_DATE: "2026-05-31",
|
||||||
COL_ISSUE_DATE: "2026-06-01",
|
COL_ISSUE_DATE: "2026-06-01",
|
||||||
}
|
}
|
||||||
assert "Project code is inactive or missing" in row_errors(row, {"DBY"})
|
assert "项目代码不存在或已停用" in row_errors(row, {"DBY"})
|
||||||
|
|
||||||
|
|
||||||
def test_date_is_valid_rejects_bad_text():
|
def test_date_is_valid_rejects_bad_text():
|
||||||
@@ -70,16 +72,20 @@ def test_import_template_contains_date_examples_and_validation():
|
|||||||
instruction_sheet = workbook["填写说明"]
|
instruction_sheet = workbook["填写说明"]
|
||||||
|
|
||||||
assert [cell.value for cell in data_sheet[1]] == TEMPLATE_HEADERS
|
assert [cell.value for cell in data_sheet[1]] == TEMPLATE_HEADERS
|
||||||
assert data_sheet.column_dimensions["D"].number_format == "yyyy-mm-dd"
|
assert data_sheet.column_dimensions["F"].number_format == "yyyy-mm-dd"
|
||||||
assert len(data_sheet.data_validations.dataValidation) == 3
|
assert len(data_sheet.data_validations.dataValidation) == 3
|
||||||
assert instruction_sheet["C5"].value == "2026-06-01"
|
instruction_rows = {row[0].value: row for row in instruction_sheet.iter_rows(min_row=2)}
|
||||||
assert "YYYY-MM-DD" in instruction_sheet["D5"].value
|
assert instruction_rows[COL_COURSE_START_DATE][2].value == "2026-06-01"
|
||||||
|
assert "实际开始日期" in instruction_rows[COL_COURSE_START_DATE][3].value
|
||||||
|
assert COL_COURSE_NAME in TEMPLATE_HEADERS
|
||||||
|
assert COL_STAGE_NAME in TEMPLATE_HEADERS
|
||||||
|
|
||||||
|
|
||||||
def test_certificate_create_rejects_reversed_course_period():
|
def test_certificate_create_rejects_reversed_course_period():
|
||||||
with pytest.raises(ValidationError, match="课程结束日期不能早于课程开始日期"):
|
with pytest.raises(ValidationError, match="课程结束日期不能早于课程开始日期"):
|
||||||
CertificateCreate(
|
CertificateCreate(
|
||||||
learner_id=1,
|
learner_name="张三",
|
||||||
|
learner_phone="13800000000",
|
||||||
project_code="DBY",
|
project_code="DBY",
|
||||||
course_start_date="2026-06-30",
|
course_start_date="2026-06-30",
|
||||||
course_end_date="2026-06-01",
|
course_end_date="2026-06-01",
|
||||||
@@ -89,7 +95,8 @@ def test_certificate_create_rejects_reversed_course_period():
|
|||||||
|
|
||||||
def test_certificate_create_has_no_issuer_field():
|
def test_certificate_create_has_no_issuer_field():
|
||||||
certificate = CertificateCreate(
|
certificate = CertificateCreate(
|
||||||
learner_id=1,
|
learner_name="张三",
|
||||||
|
learner_phone="13800000000",
|
||||||
project_code="DBY",
|
project_code="DBY",
|
||||||
course_start_date="2026-06-01",
|
course_start_date="2026-06-01",
|
||||||
course_end_date="2026-06-30",
|
course_end_date="2026-06-30",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface Learner {
|
|||||||
student_no: string | null;
|
student_no: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
|
certificate_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ export interface AdminCertificate {
|
|||||||
id: number;
|
id: number;
|
||||||
learner_id: number;
|
learner_id: number;
|
||||||
learner_name: string | null;
|
learner_name: string | null;
|
||||||
|
learner_phone: string | null;
|
||||||
import_batch_id: number | null;
|
import_batch_id: number | null;
|
||||||
project_code: string;
|
project_code: string;
|
||||||
certificate_no: string;
|
certificate_no: string;
|
||||||
@@ -101,10 +103,29 @@ export interface CertificateTemplate {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
dynamic_fields: string[];
|
dynamic_fields: string[];
|
||||||
|
fields: CertificateTemplateField[];
|
||||||
preview_url: string;
|
preview_url: string;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CertificateTemplateField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
field_type: "text" | "date";
|
||||||
|
required: boolean;
|
||||||
|
example: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearnerImportResult {
|
||||||
|
total_rows: number;
|
||||||
|
created_rows: number;
|
||||||
|
updated_rows: number;
|
||||||
|
unchanged_rows: number;
|
||||||
|
failed_rows: number;
|
||||||
|
errors: Array<{ row_no: number; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OperationLog {
|
export interface OperationLog {
|
||||||
id: number;
|
id: number;
|
||||||
admin_user_id: number | null;
|
admin_user_id: number | null;
|
||||||
|
|||||||
@@ -30,38 +30,32 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<div class="create-grid">
|
<div class="create-grid">
|
||||||
<el-form-item label="学员" required>
|
<el-form-item label="学员姓名" required>
|
||||||
<el-select v-model="form.learner_id" filterable placeholder="按姓名或手机号选择">
|
<el-input v-model.trim="form.learner_name" placeholder="请输入证书上的姓名" />
|
||||||
<el-option
|
</el-form-item>
|
||||||
v-for="item in activeLearners"
|
<el-form-item label="手机号" required>
|
||||||
:key="item.id"
|
<el-input v-model.trim="form.learner_phone" maxlength="18" placeholder="用于匹配或自动创建学员" />
|
||||||
:label="`${item.current_name} ${item.phone}`"
|
|
||||||
:value="item.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="项目" required>
|
<el-form-item label="项目" required>
|
||||||
<el-select v-model="form.project_code" filterable placeholder="选择项目" @change="handleProjectChange">
|
<el-select v-model="form.project_code" filterable placeholder="选择项目" @change="handleProjectChange">
|
||||||
<el-option v-for="item in activeProjects" :key="item.code" :label="`${item.code} ${item.name}`" :value="item.code" />
|
<el-option v-for="item in activeProjects" :key="item.code" :label="`${item.code} ${item.name}`" :value="item.code" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="课程开始日期" required>
|
<el-form-item v-if="hasTemplateField('course_start_date')" label="课程开始日期" required>
|
||||||
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
|
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="课程结束日期" required>
|
<el-form-item v-if="hasTemplateField('course_end_date')" label="课程结束日期" required>
|
||||||
<el-date-picker v-model="form.course_end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" />
|
<el-date-picker v-model="form.course_end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="发证日期" required>
|
<el-form-item v-if="hasTemplateField('issue_date')" label="发证日期" required>
|
||||||
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" placeholder="选择发证日期" />
|
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" placeholder="选择发证日期" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="form.template_code === 'classic'">
|
<el-form-item v-if="hasTemplateField('course_name')" label="课程名称" required>
|
||||||
<el-form-item label="课程名称">
|
<el-input v-model.trim="form.course_name" placeholder="留空时使用项目默认课程名称" />
|
||||||
<el-input v-model.trim="form.course_name" placeholder="留空使用项目默认值" />
|
</el-form-item>
|
||||||
</el-form-item>
|
<el-form-item v-if="hasTemplateField('stage_name')" label="阶段名称" required>
|
||||||
<el-form-item label="阶段名称">
|
<el-input v-model.trim="form.stage_name" placeholder="留空时使用项目默认阶段名称" />
|
||||||
<el-input v-model.trim="form.stage_name" placeholder="留空使用项目默认值" />
|
</el-form-item>
|
||||||
</el-form-item>
|
|
||||||
</template>
|
|
||||||
<el-form-item label="内部备注">
|
<el-form-item label="内部备注">
|
||||||
<el-input v-model.trim="form.remark" placeholder="不会显示在证书上" />
|
<el-input v-model.trim="form.remark" placeholder="不会显示在证书上" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -72,6 +66,7 @@
|
|||||||
:closable="false"
|
:closable="false"
|
||||||
:title="`${selectedTemplate.name}将填写:${selectedTemplate.dynamic_fields.join('、')}`"
|
:title="`${selectedTemplate.name}将填写:${selectedTemplate.dynamic_fields.join('、')}`"
|
||||||
/>
|
/>
|
||||||
|
<p class="identity-note">手机号已存在时会关联原学员;不存在时将按本次姓名和手机号自动创建学员。</p>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="createVisible = false">取消</el-button>
|
<el-button @click="createVisible = false">取消</el-button>
|
||||||
@@ -108,6 +103,7 @@
|
|||||||
<el-table-column type="selection" width="44" />
|
<el-table-column type="selection" width="44" />
|
||||||
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
|
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
|
||||||
<el-table-column prop="learner_name" label="学员姓名" width="100" show-overflow-tooltip />
|
<el-table-column prop="learner_name" label="学员姓名" width="100" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="learner_phone" label="手机号" width="128" />
|
||||||
<el-table-column prop="project_code" label="项目" width="72" />
|
<el-table-column prop="project_code" label="项目" width="72" />
|
||||||
<el-table-column label="模板" width="150" show-overflow-tooltip>
|
<el-table-column label="模板" width="150" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ templateName(row.template_code) }}</template>
|
<template #default="{ row }">{{ templateName(row.template_code) }}</template>
|
||||||
@@ -196,7 +192,6 @@ import {
|
|||||||
type AdminCertificate,
|
type AdminCertificate,
|
||||||
type CertificateTemplate,
|
type CertificateTemplate,
|
||||||
type ImportBatch,
|
type ImportBatch,
|
||||||
type Learner,
|
|
||||||
type PdfPregenerationJob,
|
type PdfPregenerationJob,
|
||||||
type ProjectCourse,
|
type ProjectCourse,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
@@ -205,7 +200,6 @@ import { apiErrorMessage, downloadFile } from "../download";
|
|||||||
const certificates = ref<AdminCertificate[]>([]);
|
const certificates = ref<AdminCertificate[]>([]);
|
||||||
const projects = ref<ProjectCourse[]>([]);
|
const projects = ref<ProjectCourse[]>([]);
|
||||||
const templates = ref<CertificateTemplate[]>([]);
|
const templates = ref<CertificateTemplate[]>([]);
|
||||||
const learners = ref<Learner[]>([]);
|
|
||||||
const importBatches = ref<ImportBatch[]>([]);
|
const importBatches = ref<ImportBatch[]>([]);
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const statusValue = ref("");
|
const statusValue = ref("");
|
||||||
@@ -223,7 +217,8 @@ const creating = ref(false);
|
|||||||
const templateManuallySelected = ref(false);
|
const templateManuallySelected = ref(false);
|
||||||
let pregenerationTimer: number | null = null;
|
let pregenerationTimer: number | null = null;
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
learner_id: undefined as number | undefined,
|
learner_name: "",
|
||||||
|
learner_phone: "",
|
||||||
project_code: "",
|
project_code: "",
|
||||||
course_name: "",
|
course_name: "",
|
||||||
stage_name: "",
|
stage_name: "",
|
||||||
@@ -235,7 +230,6 @@ const form = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
|
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
|
||||||
const activeLearners = computed(() => learners.value.filter((item) => item.status === "active"));
|
|
||||||
const selectedTemplate = computed(() => templates.value.find((item) => item.code === form.template_code));
|
const selectedTemplate = computed(() => templates.value.find((item) => item.code === form.template_code));
|
||||||
|
|
||||||
function statusText(status: string) {
|
function statusText(status: string) {
|
||||||
@@ -268,11 +262,6 @@ async function loadTemplates() {
|
|||||||
templates.value = data;
|
templates.value = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLearners() {
|
|
||||||
const { data } = await http.get<Learner[]>("/admin/learners");
|
|
||||||
learners.value = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadImportBatches() {
|
async function loadImportBatches() {
|
||||||
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
|
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
|
||||||
importBatches.value = data;
|
importBatches.value = data;
|
||||||
@@ -297,7 +286,8 @@ function handleSelectionChange(rows: AdminCertificate[]) {
|
|||||||
function resetCreateForm() {
|
function resetCreateForm() {
|
||||||
templateManuallySelected.value = false;
|
templateManuallySelected.value = false;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
learner_id: undefined,
|
learner_name: "",
|
||||||
|
learner_phone: "",
|
||||||
project_code: "",
|
project_code: "",
|
||||||
course_name: "",
|
course_name: "",
|
||||||
stage_name: "",
|
stage_name: "",
|
||||||
@@ -312,6 +302,12 @@ function resetCreateForm() {
|
|||||||
function selectTemplate(templateCode: string) {
|
function selectTemplate(templateCode: string) {
|
||||||
form.template_code = templateCode;
|
form.template_code = templateCode;
|
||||||
templateManuallySelected.value = true;
|
templateManuallySelected.value = true;
|
||||||
|
if (!hasTemplateField("course_name")) form.course_name = "";
|
||||||
|
if (!hasTemplateField("stage_name")) form.stage_name = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTemplateField(key: string) {
|
||||||
|
return !!selectedTemplate.value?.fields.some((field) => field.key === key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
@@ -321,32 +317,50 @@ function openCreateDialog() {
|
|||||||
|
|
||||||
function handleProjectChange(projectCode: string) {
|
function handleProjectChange(projectCode: string) {
|
||||||
const project = projects.value.find((item) => item.code === projectCode);
|
const project = projects.value.find((item) => item.code === projectCode);
|
||||||
if (project && !templateManuallySelected.value) form.template_code = project.default_template_code;
|
if (!project) return;
|
||||||
|
if (!templateManuallySelected.value) form.template_code = project.default_template_code;
|
||||||
|
if (!form.course_name) form.course_name = project.default_course_name || "";
|
||||||
|
if (!form.stage_name) form.stage_name = project.default_stage_name || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCertificate() {
|
async function createCertificate() {
|
||||||
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date) {
|
if (!form.learner_name || !form.learner_phone || !form.project_code) {
|
||||||
ElMessage.warning("请填写学员ID、项目、课程起止日期和发证日期");
|
ElMessage.warning("请填写学员姓名、手机号和项目");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (form.course_end_date < form.course_start_date) {
|
const fieldValues: Record<string, string> = {
|
||||||
|
course_name: form.course_name,
|
||||||
|
stage_name: form.stage_name,
|
||||||
|
course_start_date: form.course_start_date,
|
||||||
|
course_end_date: form.course_end_date,
|
||||||
|
issue_date: form.issue_date,
|
||||||
|
};
|
||||||
|
const missingField = selectedTemplate.value?.fields.find((field) => field.key !== "learner_name" && field.required && !fieldValues[field.key]);
|
||||||
|
const project = projects.value.find((item) => item.code === form.project_code);
|
||||||
|
const hasProjectDefault = missingField?.key === "course_name" ? project?.default_course_name : missingField?.key === "stage_name" ? project?.default_stage_name : null;
|
||||||
|
if (missingField && !hasProjectDefault) {
|
||||||
|
ElMessage.warning(`请填写${missingField.label}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.course_start_date && form.course_end_date && form.course_end_date < form.course_start_date) {
|
||||||
ElMessage.warning("课程结束日期不能早于课程开始日期");
|
ElMessage.warning("课程结束日期不能早于课程开始日期");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
creating.value = true;
|
creating.value = true;
|
||||||
try {
|
try {
|
||||||
await http.post("/admin/certificates", {
|
await http.post("/admin/certificates", {
|
||||||
learner_id: form.learner_id,
|
learner_name: form.learner_name,
|
||||||
|
learner_phone: form.learner_phone,
|
||||||
project_code: form.project_code,
|
project_code: form.project_code,
|
||||||
course_name: form.template_code === "classic" ? form.course_name || null : null,
|
course_name: hasTemplateField("course_name") ? form.course_name || null : null,
|
||||||
stage_name: form.template_code === "classic" ? form.stage_name || null : null,
|
stage_name: hasTemplateField("stage_name") ? form.stage_name || null : null,
|
||||||
course_start_date: form.course_start_date,
|
course_start_date: form.course_start_date,
|
||||||
course_end_date: form.course_end_date,
|
course_end_date: form.course_end_date,
|
||||||
issue_date: form.issue_date,
|
issue_date: form.issue_date,
|
||||||
template_code: form.template_code,
|
template_code: form.template_code,
|
||||||
remark: form.remark || null,
|
remark: form.remark || null,
|
||||||
});
|
});
|
||||||
ElMessage.success("证书已创建");
|
ElMessage.success("证书已创建,学员信息已自动关联");
|
||||||
createVisible.value = false;
|
createVisible.value = false;
|
||||||
await loadCertificates();
|
await loadCertificates();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -441,7 +455,7 @@ function stopPregenerationPolling() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadProjects(), loadTemplates(), loadLearners(), loadImportBatches(), loadCertificates()]);
|
await Promise.all([loadProjects(), loadTemplates(), loadImportBatches(), loadCertificates()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(stopPregenerationPolling);
|
onUnmounted(stopPregenerationPolling);
|
||||||
@@ -518,6 +532,12 @@ onUnmounted(stopPregenerationPolling);
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.identity-note {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--app-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.template-choice small {
|
.template-choice small {
|
||||||
color: var(--app-muted);
|
color: var(--app-muted);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
|
|||||||
@@ -5,25 +5,26 @@
|
|||||||
<h2>Excel 导入</h2>
|
<h2>Excel 导入</h2>
|
||||||
<p>上传后先校验,确认导入后才会正式写入学员和证书数据。</p>
|
<p>上传后先校验,确认导入后才会正式写入学员和证书数据。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button @click="downloadTemplate">下载模板</el-button>
|
<el-button :disabled="!selectedTemplate" @click="downloadTemplate">下载所选模板</el-button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
class="import-tip"
|
class="import-tip"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
|
title="请先选择证书模板,再下载对应 Excel。不同证书模板的列和必填项不同,不要混用文件。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="batch-template">
|
<div class="batch-template">
|
||||||
<div>
|
<div>
|
||||||
<strong>本批次证书模板</strong>
|
<strong>本批次证书模板</strong>
|
||||||
<span>确认导入后,整批证书都会固化为所选模板。</span>
|
<span>下载、校验和正式签发都以本次选择为准。</span>
|
||||||
</div>
|
</div>
|
||||||
<el-select v-model="templateCode" placeholder="选择证书模板">
|
<el-select v-model="templateCode" placeholder="选择证书模板">
|
||||||
<el-option v-for="item in templates" :key="item.code" :label="item.name" :value="item.code" />
|
<el-option v-for="item in templates" :key="item.code" :label="item.name" :value="item.code" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<img v-if="selectedTemplate" :src="selectedTemplate.preview_url" :alt="selectedTemplate.name" />
|
<img v-if="selectedTemplate" :src="selectedTemplate.preview_url" :alt="selectedTemplate.name" />
|
||||||
|
<p v-if="selectedTemplate" class="template-fields">Excel 字段:{{ selectedImportColumns.join("、") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-upload
|
<el-upload
|
||||||
@@ -129,6 +130,12 @@ let pregenerationTimer: number | null = null;
|
|||||||
|
|
||||||
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
|
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
|
||||||
const selectedTemplate = computed(() => templates.value.find((item) => item.code === templateCode.value));
|
const selectedTemplate = computed(() => templates.value.find((item) => item.code === templateCode.value));
|
||||||
|
const selectedImportColumns = computed(() => [
|
||||||
|
"姓名",
|
||||||
|
"手机号",
|
||||||
|
"项目代码",
|
||||||
|
...(selectedTemplate.value?.dynamic_fields.filter((field) => field !== "姓名") || []),
|
||||||
|
]);
|
||||||
|
|
||||||
function templateName(code: string) {
|
function templateName(code: string) {
|
||||||
return templates.value.find((item) => item.code === code)?.name || code;
|
return templates.value.find((item) => item.code === code)?.name || code;
|
||||||
@@ -149,7 +156,11 @@ function pickFile(file: UploadFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function downloadTemplate() {
|
function downloadTemplate() {
|
||||||
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
|
if (!selectedTemplate.value) return;
|
||||||
|
downloadFile(
|
||||||
|
`/admin/import-batches/template?template_code=${encodeURIComponent(templateCode.value)}`,
|
||||||
|
`certificate-import-${templateCode.value}.xlsx`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBatches() {
|
async function loadBatches() {
|
||||||
@@ -291,6 +302,13 @@ onUnmounted(stopPregenerationPolling);
|
|||||||
border: 1px solid #edf2f4;
|
border: 1px solid #edf2f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-fields {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.import-tip {
|
.import-tip {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,38 @@
|
|||||||
<h2>学员管理</h2>
|
<h2>学员管理</h2>
|
||||||
<p>支持手动新增、修改、删除和查询学员,也可以继续通过 Excel 批量导入。</p>
|
<p>支持手动新增、修改、删除和查询学员,也可以继续通过 Excel 批量导入。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<el-button type="primary" :icon="Upload" @click="openImportDialog">批量导入学员</el-button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<el-dialog v-model="importVisible" title="批量导入学员" width="640px" destroy-on-close>
|
||||||
|
<div class="import-actions">
|
||||||
|
<div>
|
||||||
|
<h3>学员 Excel</h3>
|
||||||
|
<p>必填姓名和手机号;学员编号、备注为选填。手机号已存在时姓名必须一致。</p>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Download" @click="downloadImportTemplate">下载模板</el-button>
|
||||||
|
</div>
|
||||||
|
<el-upload drag :auto-upload="false" :limit="1" accept=".xlsx" @change="pickImportFile" @remove="importFile = null">
|
||||||
|
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||||
|
<div>拖入 Excel 文件,或点击选择文件</div>
|
||||||
|
</el-upload>
|
||||||
|
<el-alert
|
||||||
|
v-if="importResult"
|
||||||
|
class="import-result"
|
||||||
|
:type="importResult.failed_rows ? 'warning' : 'success'"
|
||||||
|
:closable="false"
|
||||||
|
:title="`处理 ${importResult.total_rows} 行:新建 ${importResult.created_rows},更新 ${importResult.updated_rows},未变化 ${importResult.unchanged_rows},失败 ${importResult.failed_rows}`"
|
||||||
|
/>
|
||||||
|
<el-table v-if="importResult?.errors.length" :data="importResult.errors" size="small" max-height="220">
|
||||||
|
<el-table-column prop="row_no" label="行号" width="80" />
|
||||||
|
<el-table-column prop="message" label="失败原因" />
|
||||||
|
</el-table>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="importVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" :disabled="!importFile" :loading="importing" @click="importLearners">开始导入</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-card class="panel" shadow="never">
|
<el-card class="panel" shadow="never">
|
||||||
<div class="form-section-head">
|
<div class="form-section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -53,6 +83,7 @@
|
|||||||
<el-table-column prop="current_name" label="姓名" width="140" />
|
<el-table-column prop="current_name" label="姓名" width="140" />
|
||||||
<el-table-column prop="phone" label="手机号" width="160" />
|
<el-table-column prop="phone" label="手机号" width="160" />
|
||||||
<el-table-column prop="student_no" label="学员编号" width="150" />
|
<el-table-column prop="student_no" label="学员编号" width="150" />
|
||||||
|
<el-table-column prop="certificate_count" label="证书数量" width="100" align="center" />
|
||||||
<el-table-column label="状态" width="110">
|
<el-table-column label="状态" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ statusText(row.status) }}</el-tag>
|
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ statusText(row.status) }}</el-tag>
|
||||||
@@ -71,16 +102,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, Plus, RefreshLeft } from "@element-plus/icons-vue";
|
import { Check, Download, Plus, RefreshLeft, Upload, UploadFilled } from "@element-plus/icons-vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import { http, type Learner } from "../api";
|
import { http, type Learner, type LearnerImportResult } from "../api";
|
||||||
|
import { apiErrorMessage, downloadFile } from "../download";
|
||||||
|
|
||||||
const learners = ref<Learner[]>([]);
|
const learners = ref<Learner[]>([]);
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const editingId = ref<number | null>(null);
|
const editingId = ref<number | null>(null);
|
||||||
const form = reactive({ current_name: "", phone: "", student_no: "", status: "active", remark: "" });
|
const form = reactive({ current_name: "", phone: "", student_no: "", status: "active", remark: "" });
|
||||||
|
const importVisible = ref(false);
|
||||||
|
const importFile = ref<File | null>(null);
|
||||||
|
const importing = ref(false);
|
||||||
|
const importResult = ref<LearnerImportResult | null>(null);
|
||||||
|
|
||||||
function statusText(status: string) {
|
function statusText(status: string) {
|
||||||
return status === "active" ? "启用" : "停用";
|
return status === "active" ? "启用" : "停用";
|
||||||
@@ -130,6 +166,38 @@ async function deleteLearner(row: Learner) {
|
|||||||
await loadLearners();
|
await loadLearners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openImportDialog() {
|
||||||
|
importFile.value = null;
|
||||||
|
importResult.value = null;
|
||||||
|
importVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadImportTemplate() {
|
||||||
|
downloadFile("/admin/learners/import-template", "learner-import-template.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickImportFile(file: UploadFile) {
|
||||||
|
importFile.value = file.raw || null;
|
||||||
|
importResult.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importLearners() {
|
||||||
|
if (!importFile.value) return;
|
||||||
|
importing.value = true;
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", importFile.value);
|
||||||
|
const { data } = await http.post<LearnerImportResult>("/admin/learners/import", body);
|
||||||
|
importResult.value = data;
|
||||||
|
ElMessage.success(data.failed_rows ? "导入完成,请检查失败行" : "学员导入完成");
|
||||||
|
await loadLearners();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(await apiErrorMessage(error, "学员导入失败"));
|
||||||
|
} finally {
|
||||||
|
importing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadLearners);
|
onMounted(loadLearners);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -142,6 +210,34 @@ onMounted(loadLearners);
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-actions h3,
|
||||||
|
.import-actions p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-actions p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-result {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user