重构学员证书签发与批量导入流程
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user