重构学员证书签发与批量导入流程
This commit is contained in:
109
backend/tests/test_certificate_issuance.py
Normal file
109
backend/tests/test_certificate_issuance.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.admin_imports import COL_COURSE_NAME, COL_STAGE_NAME, build_import_template_workbook
|
||||
from app.db.base import Base
|
||||
from app.models import Certificate, Learner, ProjectCourse
|
||||
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
|
||||
from app.services.learner_identity import LearnerIdentityConflict, resolve_learner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def add_project(db: Session) -> None:
|
||||
db.add(
|
||||
ProjectCourse(
|
||||
code="DBY",
|
||||
name="大本营",
|
||||
default_certificate_name="培训结业证书",
|
||||
default_course_name="智慧赋能疗愈师",
|
||||
default_stage_name="初级",
|
||||
default_template_code="classic",
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def test_practice_template_has_only_its_required_certificate_columns():
|
||||
sheet = build_import_template_workbook("practice-camp")["证书导入模板"]
|
||||
headers = [cell.value for cell in sheet[1]]
|
||||
|
||||
assert headers == ["姓名", "手机号", "项目代码", "课程开始日期", "课程结束日期", "发证日期"]
|
||||
assert COL_COURSE_NAME not in headers
|
||||
assert COL_STAGE_NAME not in headers
|
||||
|
||||
|
||||
def test_learner_identity_reuses_exact_name_and_phone_but_rejects_name_conflict(db: Session):
|
||||
learner, created = resolve_learner(db, "张三", "138-0000-0000", source="test")
|
||||
reused, reused_created = resolve_learner(db, "张三", "+86 13800000000", source="test")
|
||||
|
||||
assert created is True
|
||||
assert reused_created is False
|
||||
assert reused.id == learner.id
|
||||
with pytest.raises(LearnerIdentityConflict, match="姓名.*不一致"):
|
||||
resolve_learner(db, "李四", "13800000000", source="test")
|
||||
|
||||
|
||||
def test_one_learner_can_hold_multiple_template_certificates(db: Session):
|
||||
add_project(db)
|
||||
common = dict(
|
||||
learner_name="张三",
|
||||
learner_phone="13800000000",
|
||||
project_code="DBY",
|
||||
course_start_date=date(2026, 3, 1),
|
||||
course_end_date=date(2026, 8, 31),
|
||||
)
|
||||
classic, _, classic_created = issue_certificate(
|
||||
db,
|
||||
CertificateIssueData(
|
||||
**common,
|
||||
template_code="classic",
|
||||
issue_date=date(2026, 9, 5),
|
||||
course_name="智慧赋能疗愈师",
|
||||
stage_name="初级",
|
||||
),
|
||||
source="test",
|
||||
)
|
||||
practice, _, practice_created = issue_certificate(
|
||||
db,
|
||||
CertificateIssueData(
|
||||
**common,
|
||||
template_code="practice-camp",
|
||||
issue_date=date(2026, 9, 6),
|
||||
),
|
||||
source="test",
|
||||
)
|
||||
|
||||
assert classic_created is True
|
||||
assert practice_created is False
|
||||
assert classic.learner_id == practice.learner_id
|
||||
assert db.query(Learner).count() == 1
|
||||
assert db.query(Certificate).count() == 2
|
||||
assert practice.course_name is None
|
||||
assert practice.stage_name is None
|
||||
|
||||
|
||||
def test_duplicate_certificate_is_rejected(db: Session):
|
||||
add_project(db)
|
||||
data = CertificateIssueData(
|
||||
learner_name="张三",
|
||||
learner_phone="13800000000",
|
||||
project_code="DBY",
|
||||
template_code="practice-camp",
|
||||
course_start_date=date(2026, 3, 1),
|
||||
course_end_date=date(2026, 8, 31),
|
||||
issue_date=date(2026, 9, 5),
|
||||
)
|
||||
issue_certificate(db, data, source="test")
|
||||
|
||||
with pytest.raises(DuplicateCertificate):
|
||||
issue_certificate(db, data, source="test")
|
||||
@@ -6,10 +6,12 @@ from pydantic import ValidationError
|
||||
from app.api.routes.admin_imports import (
|
||||
COL_ISSUE_DATE,
|
||||
COL_COURSE_END_DATE,
|
||||
COL_COURSE_NAME,
|
||||
COL_COURSE_START_DATE,
|
||||
COL_NAME,
|
||||
COL_PHONE,
|
||||
COL_PROJECT,
|
||||
COL_STAGE_NAME,
|
||||
TEMPLATE_HEADERS,
|
||||
build_import_template_workbook,
|
||||
date_is_valid,
|
||||
@@ -33,7 +35,7 @@ def test_row_errors_require_project_code_to_exist():
|
||||
COL_COURSE_END_DATE: "2026-05-31",
|
||||
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():
|
||||
@@ -70,16 +72,20 @@ def test_import_template_contains_date_examples_and_validation():
|
||||
instruction_sheet = workbook["填写说明"]
|
||||
|
||||
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 instruction_sheet["C5"].value == "2026-06-01"
|
||||
assert "YYYY-MM-DD" in instruction_sheet["D5"].value
|
||||
instruction_rows = {row[0].value: row for row in instruction_sheet.iter_rows(min_row=2)}
|
||||
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():
|
||||
with pytest.raises(ValidationError, match="课程结束日期不能早于课程开始日期"):
|
||||
CertificateCreate(
|
||||
learner_id=1,
|
||||
learner_name="张三",
|
||||
learner_phone="13800000000",
|
||||
project_code="DBY",
|
||||
course_start_date="2026-06-30",
|
||||
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():
|
||||
certificate = CertificateCreate(
|
||||
learner_id=1,
|
||||
learner_name="张三",
|
||||
learner_phone="13800000000",
|
||||
project_code="DBY",
|
||||
course_start_date="2026-06-01",
|
||||
course_end_date="2026-06-30",
|
||||
|
||||
Reference in New Issue
Block a user