重构学员证书签发与批量导入流程

This commit is contained in:
Certificate System
2026-08-14 11:37:09 +08:00
parent 522f529c6a
commit 5e33657268
16 changed files with 1047 additions and 370 deletions

View File

@@ -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,

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View 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

View File

@@ -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}

View 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

View 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 参数、事务提交和操作日志,不应再次复制签发规则。

View 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")

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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;
} }