新增课程起止日期管理

This commit is contained in:
Certificate System
2026-08-13 14:39:44 +08:00
parent 2e1a639179
commit c9667d56fb
20 changed files with 313 additions and 33 deletions

View File

@@ -0,0 +1,26 @@
"""add certificate course period
Revision ID: 20260813_0006
Revises: 20260623_0005
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0006"
down_revision: Union[str, None] = "20260623_0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("certificates", sa.Column("course_start_date", sa.Date(), nullable=True))
op.add_column("certificates", sa.Column("course_end_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("certificates", "course_end_date")
op.drop_column("certificates", "course_start_date")

View File

@@ -82,6 +82,8 @@ def create_certificate(
class_name=payload.class_name, class_name=payload.class_name,
course_name=payload.course_name or project.default_course_name or project.name, course_name=payload.course_name or project.default_course_name or project.name,
stage_name=payload.stage_name or project.default_stage_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, issue_date=payload.issue_date,
issuer_name=payload.issuer_name or project.default_issuer_name, issuer_name=payload.issuer_name or project.default_issuer_name,
remark=payload.remark, remark=payload.remark,
@@ -166,6 +168,8 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
"class_name": certificate.class_name, "class_name": certificate.class_name,
"course_name": certificate.course_name, "course_name": certificate.course_name,
"stage_name": certificate.stage_name, "stage_name": certificate.stage_name,
"course_start_date": certificate.course_start_date,
"course_end_date": certificate.course_end_date,
"issue_date": certificate.issue_date, "issue_date": certificate.issue_date,
"issuer_name": certificate.issuer_name, "issuer_name": certificate.issuer_name,
"status": certificate.status, "status": certificate.status,
@@ -234,6 +238,8 @@ def preview_certificate(
"project_code": certificate.project_code, "project_code": certificate.project_code,
"course_name": certificate.course_name, "course_name": certificate.course_name,
"stage_name": certificate.stage_name, "stage_name": certificate.stage_name,
"course_start_date": certificate.course_start_date.isoformat() if certificate.course_start_date else None,
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(), "issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name, "issuer_name": certificate.issuer_name,
"status": certificate.status, "status": certificate.status,

View File

@@ -44,6 +44,8 @@ def export_certificates(
"证书名称", "证书名称",
"课程名称", "课程名称",
"阶段名称", "阶段名称",
"课程开始日期",
"课程结束日期",
"发证日期", "发证日期",
"证书状态", "证书状态",
"PDF状态", "PDF状态",
@@ -60,6 +62,8 @@ def export_certificates(
certificate.certificate_name, certificate.certificate_name,
certificate.course_name, certificate.course_name,
certificate.stage_name, certificate.stage_name,
certificate.course_start_date.isoformat() if certificate.course_start_date else "",
certificate.course_end_date.isoformat() if certificate.course_end_date else "",
certificate.issue_date.isoformat(), certificate.issue_date.isoformat(),
certificate.status, certificate.status,
certificate.pdf_status, certificate.pdf_status,

View File

@@ -6,6 +6,9 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, 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.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.datavalidation import DataValidation
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import require_roles from app.api.deps import require_roles
@@ -31,23 +34,24 @@ router = APIRouter()
COL_NAME = "\u59d3\u540d" COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7" COL_PHONE = "\u624b\u673a\u53f7"
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801" COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
COL_COURSE_START_DATE = "课程开始日期"
COL_COURSE_END_DATE = "课程结束日期"
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f" COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
TEMPLATE_HEADERS = [ TEMPLATE_HEADERS = [
COL_NAME, COL_NAME,
COL_PHONE, COL_PHONE,
COL_PROJECT, COL_PROJECT,
COL_COURSE_START_DATE,
COL_COURSE_END_DATE,
COL_ISSUE_DATE, COL_ISSUE_DATE,
] ]
REQUIRED_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE] 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(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
workbook = Workbook() workbook = build_import_template_workbook()
sheet = workbook.active
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
sheet.append(TEMPLATE_HEADERS)
stream_path = data_path("exports") / "certificate-import-template.xlsx" stream_path = data_path("exports") / "certificate-import-template.xlsx"
workbook.save(stream_path) workbook.save(stream_path)
file_handle = stream_path.open("rb") file_handle = stream_path.open("rb")
@@ -58,6 +62,16 @@ def download_template(_: AdminUser = Depends(require_roles("system_admin", "cert
) )
def build_import_template_workbook() -> Workbook:
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
sheet.append(TEMPLATE_HEADERS)
_format_template_sheet(sheet)
_add_template_instructions(workbook)
return workbook
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
def upload_import_file( def upload_import_file(
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -164,6 +178,8 @@ def confirm_import_batch(
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) learner = upsert_learner(db, row_data)
course_start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
course_end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
issue_date = parse_issue_date(row_data[COL_ISSUE_DATE]) issue_date = parse_issue_date(row_data[COL_ISSUE_DATE])
project_code = str(row_data[COL_PROJECT]).strip().upper() project_code = str(row_data[COL_PROJECT]).strip().upper()
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first() project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
@@ -172,7 +188,7 @@ def confirm_import_batch(
row.error_message = f"Project code is inactive or missing: {project_code}" row.error_message = f"Project code is inactive or missing: {project_code}"
failed_rows += 1 failed_rows += 1
continue continue
duplicate = find_duplicate_certificate(db, learner.id, project, issue_date) duplicate = find_duplicate_certificate(db, learner.id, project, course_start_date, course_end_date, issue_date)
if duplicate: if duplicate:
row.status = "skipped" row.status = "skipped"
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406" row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
@@ -187,6 +203,8 @@ def confirm_import_batch(
certificate_name=project.default_certificate_name, certificate_name=project.default_certificate_name,
course_name=project.default_course_name, course_name=project.default_course_name,
stage_name=project.default_stage_name, stage_name=project.default_stage_name,
course_start_date=course_start_date,
course_end_date=course_end_date,
issue_date=issue_date, issue_date=issue_date,
issuer_name=project.default_issuer_name, issuer_name=project.default_issuer_name,
remark=None, remark=None,
@@ -266,8 +284,14 @@ def row_errors(row_data: dict[str, object], active_codes: set[str]) -> list[str]
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("Project code is inactive or missing")
if row_data.get(COL_ISSUE_DATE) and not date_is_valid(row_data[COL_ISSUE_DATE]): for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE, COL_ISSUE_DATE]:
errors.append("Issue date format is invalid") if row_data.get(column) and not date_is_valid(row_data[column]):
errors.append(f"{column}格式错误请使用YYYY-MM-DD例如2026-06-01")
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)
end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
if end_date < start_date:
errors.append("课程结束日期不能早于课程开始日期")
return errors return errors
@@ -295,7 +319,14 @@ def get_active_project(db: Session, project_code: str) -> ProjectCourse:
return project return project
def find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCourse, issue_date: date) -> Certificate | None: def find_duplicate_certificate(
db: Session,
learner_id: int,
project: ProjectCourse,
course_start_date: date,
course_end_date: date,
issue_date: date,
) -> Certificate | None:
return ( return (
db.query(Certificate) db.query(Certificate)
.filter(Certificate.learner_id == learner_id) .filter(Certificate.learner_id == learner_id)
@@ -303,6 +334,8 @@ def find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCou
.filter(Certificate.certificate_name == project.default_certificate_name) .filter(Certificate.certificate_name == project.default_certificate_name)
.filter(Certificate.course_name == project.default_course_name) .filter(Certificate.course_name == project.default_course_name)
.filter(Certificate.stage_name == project.default_stage_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.issue_date == issue_date)
.first() .first()
) )
@@ -329,6 +362,10 @@ def optional_text(value: object) -> str | None:
def parse_issue_date(value: object) -> date: def parse_issue_date(value: object) -> date:
return parse_date(value, COL_ISSUE_DATE)
def parse_date(value: object, field_name: str = "日期") -> date:
if isinstance(value, datetime): if isinstance(value, datetime):
return value.date() return value.date()
if isinstance(value, date): if isinstance(value, date):
@@ -339,7 +376,7 @@ def parse_issue_date(value: object) -> 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"Invalid issue date: {text}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name}格式错误请使用YYYY-MM-DD{text}")
def date_is_valid(value: object) -> bool: def date_is_valid(value: object) -> bool:
@@ -361,3 +398,62 @@ def write_error_report(db: Session, batch_id: int) -> Path:
report_path = data_path("error-reports") / f"import-errors-{batch_id}.xlsx" report_path = data_path("error-reports") / f"import-errors-{batch_id}.xlsx"
workbook.save(report_path) workbook.save(report_path)
return report_path return report_path
def _format_template_sheet(sheet) -> None:
header_fill = PatternFill("solid", fgColor="208A87")
for cell in sheet[1]:
cell.fill = header_fill
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = f"A1:{sheet.cell(1, len(TEMPLATE_HEADERS)).coordinate}"
widths = [16, 18, 16, 18, 18, 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 = "@"
for column_index in [4, 5, 6]:
column_letter = sheet.cell(1, column_index).column_letter
sheet.column_dimensions[column_letter].number_format = "yyyy-mm-dd"
validation = DataValidation(type="date", operator="between", formula1="DATE(2000,1,1)", formula2="DATE(2100,12,31)", allow_blank=False)
validation.promptTitle = "日期格式"
validation.prompt = "请按 YYYY-MM-DD 填写,例如 2026-06-01"
validation.errorTitle = "日期格式错误"
validation.error = "请填写 2000-01-01 至 2100-12-31 之间的有效日期"
validation.errorStyle = "stop"
validation.showInputMessage = True
validation.showErrorMessage = True
sheet.add_data_validation(validation)
validation.add(f"{column_letter}2:{column_letter}5000")
sheet.cell(1, column_index).comment = Comment("必填。请使用 YYYY-MM-DD 格式,例如 2026-06-01。", "证书管理系统")
def _add_template_instructions(workbook: Workbook) -> None:
sheet = workbook.create_sheet("填写说明")
sheet.append(["字段", "是否必填", "格式或示例", "填写说明"])
rows = [
(COL_NAME, "", "张三", "填写学员真实姓名"),
(COL_PHONE, "", "13800000000", "建议将单元格设为文本,避免手机号格式变化"),
(COL_PROJECT, "", "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:
sheet.append(row)
sheet.append([])
sheet.append(["重要提示", "请在“证书导入模板”工作表填写正式数据,不要修改第一行列名。日期统一填写为 YYYY-MM-DD例如 2026-06-01。"])
sheet.merge_cells(start_row=9, start_column=2, end_row=9, end_column=4)
for cell in sheet[1]:
cell.fill = PatternFill("solid", fgColor="208A87")
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center")
sheet["A9"].font = Font(color="C00000", bold=True)
sheet["B9"].font = Font(color="C00000", bold=True)
sheet["B9"].alignment = Alignment(wrap_text=True, vertical="center")
sheet.row_dimensions[9].height = 34
sheet.column_dimensions["A"].width = 20
sheet.column_dimensions["B"].width = 16
sheet.column_dimensions["C"].width = 22
sheet.column_dimensions["D"].width = 54
sheet.freeze_panes = "A2"

View File

@@ -176,6 +176,8 @@ def public_certificate_payload(db: Session, certificate: Certificate, learner: L
"project_code": certificate.project_code, "project_code": certificate.project_code,
"course_name": certificate.course_name, "course_name": certificate.course_name,
"stage_name": certificate.stage_name, "stage_name": certificate.stage_name,
"course_start_date": certificate.course_start_date.isoformat() if certificate.course_start_date else None,
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(), "issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name, "issuer_name": certificate.issuer_name,
"status": certificate.status, "status": certificate.status,

View File

@@ -33,6 +33,8 @@ class Certificate(Base):
class_name: Mapped[str | None] = mapped_column(String(128), nullable=True) class_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
course_name: Mapped[str | None] = mapped_column(String(128), nullable=True) course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True) stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
course_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
course_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
issue_date: Mapped[date] = mapped_column(Date) issue_date: Mapped[date] = mapped_column(Date)
issuer_name: Mapped[str] = mapped_column(String(128)) issuer_name: Mapped[str] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(32), default="valid") status: Mapped[str] = mapped_column(String(32), default="valid")

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime from datetime import date, datetime
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, ValidationInfo, field_validator
class CertificateCreate(BaseModel): class CertificateCreate(BaseModel):
@@ -10,6 +10,8 @@ class CertificateCreate(BaseModel):
class_name: str | None = Field(default=None, max_length=128) class_name: str | None = Field(default=None, max_length=128)
course_name: str | None = Field(default=None, max_length=128) course_name: str | None = Field(default=None, max_length=128)
stage_name: str | None = Field(default=None, max_length=128) stage_name: str | None = Field(default=None, max_length=128)
course_start_date: date
course_end_date: date
issue_date: date issue_date: date
issuer_name: str = Field(min_length=1, max_length=128) issuer_name: str = Field(min_length=1, max_length=128)
remark: str | None = None remark: str | None = None
@@ -19,6 +21,14 @@ 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("course_end_date")
@classmethod
def validate_course_period(cls, value: date, info: ValidationInfo) -> date:
start_date = info.data.get("course_start_date")
if start_date and value < start_date:
raise ValueError("课程结束日期不能早于课程开始日期")
return value
class CertificateOut(BaseModel): class CertificateOut(BaseModel):
id: int id: int
@@ -31,6 +41,8 @@ class CertificateOut(BaseModel):
class_name: str | None class_name: str | None
course_name: str | None course_name: str | None
stage_name: str | None stage_name: str | None
course_start_date: date | None
course_end_date: date | None
issue_date: date issue_date: date
issuer_name: str issuer_name: str
status: str status: str

View File

@@ -121,6 +121,8 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
text_scale = (x_scale + y_scale) / 2 text_scale = (x_scale + y_scale) / 2
issue_year, issue_month, issue_day = date_parts(certificate.issue_date) issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
start_year, start_month, start_day = date_parts(certificate.course_start_date or certificate.issue_date)
end_year, end_month, end_day = date_parts(certificate.course_end_date or certificate.issue_date)
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code) course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
stage_input = (certificate.stage_name or "").strip() stage_input = (certificate.stage_name or "").strip()
stage_name = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" stage_name = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
@@ -138,17 +140,17 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
x = 185.0 * x_scale x = 185.0 * x_scale
y = 494 * y_scale y = 494 * y_scale
x = draw_inline(draw, x, y, "\u5728", 24, 18, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, "\u5728", 24, 18, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, start_year, 24, 8, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, start_month, 24, 6, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, start_day, 24, 4, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, end_year, 24, 8, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, end_month, 24, 6, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, end_day, 24, 4, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale) x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale)
draw_inline_flow( draw_inline_flow(
draw, draw,

View File

@@ -1,14 +1,22 @@
from datetime import date from datetime import date
import pytest
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_START_DATE,
COL_NAME, COL_NAME,
COL_PHONE, COL_PHONE,
COL_PROJECT, COL_PROJECT,
TEMPLATE_HEADERS,
build_import_template_workbook,
date_is_valid, date_is_valid,
parse_issue_date, parse_issue_date,
row_errors, row_errors,
) )
from app.schemas.certificate import CertificateCreate
def test_parse_issue_date_accepts_common_formats(): def test_parse_issue_date_accepts_common_formats():
@@ -21,6 +29,8 @@ def test_row_errors_require_project_code_to_exist():
COL_NAME: "张三", COL_NAME: "张三",
COL_PHONE: "13800000000", COL_PHONE: "13800000000",
COL_PROJECT: "BAD", COL_PROJECT: "BAD",
COL_COURSE_START_DATE: "2026-05-01",
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 "Project code is inactive or missing" in row_errors(row, {"DBY"})
@@ -28,3 +38,51 @@ def test_row_errors_require_project_code_to_exist():
def test_date_is_valid_rejects_bad_text(): def test_date_is_valid_rejects_bad_text():
assert not date_is_valid("not-a-date") assert not date_is_valid("not-a-date")
def test_row_errors_rejects_reversed_course_period():
row = {
COL_NAME: "张三",
COL_PHONE: "13800000000",
COL_PROJECT: "DBY",
COL_COURSE_START_DATE: "2026-06-30",
COL_COURSE_END_DATE: "2026-06-01",
COL_ISSUE_DATE: "2026-07-05",
}
assert "课程结束日期不能早于课程开始日期" in row_errors(row, {"DBY"})
def test_row_errors_explains_required_date_format():
row = {
COL_NAME: "张三",
COL_PHONE: "13800000000",
COL_PROJECT: "DBY",
COL_COURSE_START_DATE: "2026年6月1日",
COL_COURSE_END_DATE: "2026-06-30",
COL_ISSUE_DATE: "2026-07-05",
}
assert "课程开始日期格式错误请使用YYYY-MM-DD例如2026-06-01" in row_errors(row, {"DBY"})
def test_import_template_contains_date_examples_and_validation():
workbook = build_import_template_workbook()
data_sheet = workbook["证书导入模板"]
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 len(data_sheet.data_validations.dataValidation) == 3
assert instruction_sheet["C5"].value == "2026-06-01"
assert "YYYY-MM-DD" in instruction_sheet["D5"].value
def test_certificate_create_rejects_reversed_course_period():
with pytest.raises(ValidationError, match="课程结束日期不能早于课程开始日期"):
CertificateCreate(
learner_id=1,
project_code="DBY",
course_start_date="2026-06-30",
course_end_date="2026-06-01",
issue_date="2026-07-05",
issuer_name="测试单位",
)

View File

@@ -85,7 +85,7 @@
### C009 Excel 导入字段 ### C009 Excel 导入字段
- 决定:导入模板保留姓名、手机号、项目代码、发证日期。 - 决定:导入模板保留姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期。
- 原因:证书名称、课程名称、阶段名称、发证单位属于项目配置,不应该在每一行 Excel 里重复填写。 - 原因:证书名称、课程名称、阶段名称、发证单位属于项目配置,不应该在每一行 Excel 里重复填写。
- 备注:导入备注字段一期移除;批量导入后的备注缺少明确展示和业务使用场景。 - 备注:导入备注字段一期移除;批量导入后的备注缺少明确展示和业务使用场景。
@@ -98,7 +98,7 @@
- Decision: keep `certificate-template-lab` as a standalone visual tuning page. - Decision: keep `certificate-template-lab` as a standalone visual tuning page.
- Decision: production PDF rendering now uses the approved certificate image as the base layer, then renders learner/course/date/certificate number dynamically with Pillow. - Decision: production PDF rendering now uses the approved certificate image as the base layer, then renders learner/course/date/certificate number dynamically with Pillow.
- Current limitation: the data model only has `issue_date`; until separate training start/end dates are added, the rendered course date range uses `issue_date` for both start and end. - Current behavior: the rendered course date range uses the separate course start/end dates; legacy certificates without them fall back to `issue_date`.
- Current limitation: mentor, issuer names, and QR area remain from the approved base image for visual consistency. - Current limitation: mentor, issuer names, and QR area remain from the approved base image for visual consistency.
### C012 PDF 批量预生成 ### C012 PDF 批量预生成
@@ -107,3 +107,10 @@
- 策略:预生成继续使用系统设置里的 `PDF 生成并发限制`,不单独开无限制后台任务。 - 策略:预生成继续使用系统设置里的 `PDF 生成并发限制`,不单独开无限制后台任务。
- 原因:大批量学员毕业前先生成缓存,可以降低公开查询高峰的等待时间和 CPU 压力。 - 原因:大批量学员毕业前先生成缓存,可以降低公开查询高峰的等待时间和 CPU 压力。
- 当前限制:任务进度保存在后端进程内,服务重启后只保留已生成的 PDF 缓存,不恢复历史进度。 - 当前限制:任务进度保存在后端进程内,服务重启后只保留已生成的 PDF 缓存,不恢复历史进度。
### C013 课程起止日期独立建模
- 决定:证书增加“课程开始日期”和“课程结束日期”两个独立字段,不再用发证日期代替课程时间。
- 原因:课程学习周期与证书签发日期含义不同,拆分后可校验时间顺序,也便于后续按开班、结业周期统计。
- 导入约定:两个课程日期与发证日期均必填,模板统一推荐 `YYYY-MM-DD`,例如 `2026-06-01`;课程结束日期不得早于开始日期。
- 兼容策略:历史证书没有课程日期时,展示和 PDF 继续回退到发证日期,避免升级后旧证书无法查看或下载。

View File

@@ -38,6 +38,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 发证单位名称已确认。 - 发证单位名称已确认。
- 系统设置里的 `PDF 生成并发限制` 已确认2 核 4G 建议保持默认 `2` - 系统设置里的 `PDF 生成并发限制` 已确认2 核 4G 建议保持默认 `2`
- Excel 导入模板已下载并试填。 - Excel 导入模板已下载并试填。
- 导入模板中的课程开始日期、课程结束日期和发证日期已统一使用 `YYYY-MM-DD`,例如 `2026-06-01`
- 占位证书模板已确认可临时使用。 - 占位证书模板已确认可临时使用。
- 大批量毕业前的操作约定已确认确认导入后先在“Excel 导入”或“证书管理”里批量预生成 PDF。 - 大批量毕业前的操作约定已确认确认导入后先在“Excel 导入”或“证书管理”里批量预生成 PDF。
@@ -49,6 +50,8 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 可上传 Excel 并确认导入。 - 可上传 Excel 并确认导入。
- 已导入批次可以启动“预生成PDF”进度弹窗能显示总数、已完成、失败数和并发数。 - 已导入批次可以启动“预生成PDF”进度弹窗能显示总数、已完成、失败数和并发数。
- 证书管理页可按导入批次和 PDF 状态筛选,并能勾选多张证书批量预生成。 - 证书管理页可按导入批次和 PDF 状态筛选,并能勾选多张证书批量预生成。
- 倒序的课程起止日期会在手工新增和 Excel 导入校验阶段被拦截。
- 新证书正文显示课程起止日期,底部发证日期仍显示证书签发日期。
- 可导出证书和直达链接。 - 可导出证书和直达链接。
- 公开查询支持两种方式: - 公开查询支持两种方式:
- 证书编号查询:必须输入姓名和图形验证码。 - 证书编号查询:必须输入姓名和图形验证码。

View File

@@ -39,11 +39,8 @@
1. 姓名 1. 姓名
2. 手机号 2. 手机号
3. 项目代码 3. 项目代码
4. 证书名称 4. 课程开始日期
5. 课程名称 5. 课程结束日期
6. 阶段名称 6. 发证日期
7. 发证日期
8. 发证单位
9. 备注
证书编号不在 Excel 中填写,由系统确认导入后自动生成。 三个日期统一使用 `YYYY-MM-DD`,例如 `2026-06-01`,课程结束日期不得早于开始日期。证书名称、课程名称、阶段名称和发证单位从项目配置自动带出;证书编号由系统确认导入后自动生成。

View File

@@ -42,7 +42,7 @@ reset-local-data.bat
2. 进入“项目管理”,维护 `DBY``QSX` 等项目代码。项目支持搜索、修改、启用和停用。 2. 进入“项目管理”,维护 `DBY``QSX` 等项目代码。项目支持搜索、修改、启用和停用。
3. 进入“学员管理”,手动新增、查询、修改或删除学员。 3. 进入“学员管理”,手动新增、查询、修改或删除学员。
4. 进入“证书管理”手动新增证书支持单张预览、PDF 下载、作废、重置直达链接、按条件筛选、勾选后批量预生成 PDF 和导出。 4. 进入“证书管理”手动新增证书支持单张预览、PDF 下载、作废、重置直达链接、按条件筛选、勾选后批量预生成 PDF 和导出。
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果确认后正式导入。模板需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。 5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果确认后正式导入。模板需要填写姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期;三个日期统一按 `YYYY-MM-DD` 填写,例如 `2026-06-01`证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。 “确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
确认导入后可以在该批次行点击“预生成PDF”系统会提前为这一批有效证书生成 PDF并在弹窗里显示总数、已完成、新生成、已缓存、跳过和失败数量。 确认导入后可以在该批次行点击“预生成PDF”系统会提前为这一批有效证书生成 PDF并在弹窗里显示总数、已完成、新生成、已缓存、跳过和失败数量。
6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2` 6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`
@@ -143,7 +143,7 @@ docker compose exec backend pytest
- 学员手动新增、查询、修改、删除。 - 学员手动新增、查询、修改、删除。
- 证书新增、查询、后台预览、后台下载、作废、重置直达链接。 - 证书新增、查询、后台预览、后台下载、作废、重置直达链接。
- 证书按导入批次筛选、按 PDF 状态筛选、批量预生成 PDF。 - 证书按导入批次筛选、按 PDF 状态筛选、批量预生成 PDF。
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期 - Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期,并附有日期格式示例和填写说明
- 导入批次原文件下载和导入批次删除。 - 导入批次原文件下载和导入批次删除。
- 导入后自动生成证书编号、直达链接 token、二维码 token。 - 导入后自动生成证书编号、直达链接 token、二维码 token。
- 证书导出,包含证书编号和对外直达链接。 - 证书导出,包含证书编号和对外直达链接。

View File

@@ -529,12 +529,15 @@ https://你的域名/admin/login
姓名 姓名
手机号 手机号
项目代码 项目代码
课程开始日期
课程结束日期
发证日期 发证日期
``` ```
检查: 检查:
- 能下载模板。 - 能下载模板。
- 模板“填写说明”页包含 `YYYY-MM-DD` 示例,课程结束日期早于开始日期时校验失败。
- 能上传 Excel。 - 能上传 Excel。
- 错误数据能生成错误报告。 - 错误数据能生成错误报告。
- 校验通过后能确认导入。 - 校验通过后能确认导入。

View File

@@ -520,18 +520,21 @@ https://你的域名/admin/login
### 13.5 Excel 导入 ### 13.5 Excel 导入
确认模板字段只有 确认模板字段
```text ```text
姓名 姓名
手机号 手机号
项目代码 项目代码
课程开始日期
课程结束日期
发证日期 发证日期
``` ```
确认: 确认:
- 能上传 Excel。 - 能上传 Excel。
- 模板“填写说明”页包含 `YYYY-MM-DD` 示例,课程结束日期早于开始日期时校验失败。
- 能下载原文件。 - 能下载原文件。
- 能删除导入批次。 - 能删除导入批次。
- 能确认导入。 - 能确认导入。

View File

@@ -19,6 +19,8 @@ export interface PublicCertificate {
project_code: string; project_code: string;
course_name: string | null; course_name: string | null;
stage_name: string | null; stage_name: string | null;
course_start_date: string | null;
course_end_date: string | null;
issue_date: string; issue_date: string;
issuer_name: string; issuer_name: string;
status: string; status: string;
@@ -70,6 +72,8 @@ export interface AdminCertificate {
class_name: string | null; class_name: string | null;
course_name: string | null; course_name: string | null;
stage_name: string | null; stage_name: string | null;
course_start_date: string | null;
course_end_date: string | null;
issue_date: string; issue_date: string;
issuer_name: string; issuer_name: string;
status: string; status: string;

View File

@@ -21,6 +21,13 @@
<el-form-item label="发证日期"> <el-form-item label="发证日期">
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
<el-form-item label="课程开始日期">
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
</el-form-item>
<el-form-item label="课程结束日期">
<el-date-picker v-model="form.course_end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" />
<div class="form-tip">证书正文将显示这段课程起止时间结束日期不能早于开始日期</div>
</el-form-item>
<el-form-item label="课程名称"> <el-form-item label="课程名称">
<el-input v-model.trim="form.course_name" /> <el-input v-model.trim="form.course_name" />
</el-form-item> </el-form-item>
@@ -64,6 +71,9 @@
<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="project_code" label="项目" width="72" /> <el-table-column prop="project_code" label="项目" width="72" />
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip /> <el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
<el-table-column label="课程时间" width="190">
<template #default="{ row }">{{ coursePeriodText(row) }}</template>
</el-table-column>
<el-table-column prop="issue_date" label="发证日期" width="108" /> <el-table-column prop="issue_date" label="发证日期" width="108" />
<el-table-column label="PDF" width="92"> <el-table-column label="PDF" width="92">
<template #default="{ row }"> <template #default="{ row }">
@@ -96,6 +106,7 @@
<el-descriptions-item label="项目/课程/阶段"> <el-descriptions-item label="项目/课程/阶段">
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }} {{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="课程时间">{{ coursePeriodText(preview) }}</el-descriptions-item>
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item> <el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ preview.issuer_name }}</el-descriptions-item> <el-descriptions-item label="发证单位">{{ preview.issuer_name }}</el-descriptions-item>
<el-descriptions-item label="公开链接"> <el-descriptions-item label="公开链接">
@@ -160,6 +171,8 @@ const form = reactive({
project_code: "", project_code: "",
course_name: "", course_name: "",
stage_name: "", stage_name: "",
course_start_date: "",
course_end_date: "",
issue_date: "", issue_date: "",
issuer_name: "", issuer_name: "",
remark: "", remark: "",
@@ -175,6 +188,12 @@ function pdfStatusText(status: string) {
return { generated: "已生成", cleaned: "已清理", not_generated: "未生成" }[status] || status; return { generated: "已生成", cleaned: "已清理", not_generated: "未生成" }[status] || status;
} }
function coursePeriodText(certificate: Pick<AdminCertificate, "course_start_date" | "course_end_date" | "issue_date">) {
const start = certificate.course_start_date || certificate.issue_date;
const end = certificate.course_end_date || certificate.issue_date;
return `${start}${end}`;
}
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || "")); const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
async function loadProjects() { async function loadProjects() {
@@ -204,8 +223,12 @@ function handleSelectionChange(rows: AdminCertificate[]) {
} }
async function createCertificate() { async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) { if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date || !form.issuer_name) {
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位"); ElMessage.warning("请填写学员ID、项目、课程起止日期、发证日期和发证单位");
return;
}
if (form.course_end_date < form.course_start_date) {
ElMessage.warning("课程结束日期不能早于课程开始日期");
return; return;
} }
await http.post("/admin/certificates", { await http.post("/admin/certificates", {
@@ -213,6 +236,8 @@ async function createCertificate() {
project_code: form.project_code, project_code: form.project_code,
course_name: form.course_name || null, course_name: form.course_name || null,
stage_name: form.stage_name || null, stage_name: form.stage_name || null,
course_start_date: form.course_start_date,
course_end_date: form.course_end_date,
issue_date: form.issue_date, issue_date: form.issue_date,
issuer_name: form.issuer_name, issuer_name: form.issuer_name,
remark: form.remark || null, remark: form.remark || null,

View File

@@ -8,6 +8,13 @@
<el-button @click="downloadTemplate">下载模板</el-button> <el-button @click="downloadTemplate">下载模板</el-button>
</header> </header>
<el-alert
class="import-tip"
type="info"
:closable="false"
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
/>
<el-upload <el-upload
class="upload" class="upload"
drag drag
@@ -224,9 +231,14 @@ onUnmounted(stopPregenerationPolling);
} }
.upload { .upload {
margin-top: 16px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.import-tip {
margin-bottom: 4px;
}
.upload-icon { .upload-icon {
font-size: 36px; font-size: 36px;
color: #208a87; color: #208a87;

View File

@@ -17,6 +17,10 @@
<dt>证书编号</dt> <dt>证书编号</dt>
<dd>{{ certificate.certificate_no }}</dd> <dd>{{ certificate.certificate_no }}</dd>
</div> </div>
<div>
<dt>课程时间</dt>
<dd>{{ coursePeriodText }}</dd>
</div>
<div> <div>
<dt>发证日期</dt> <dt>发证日期</dt>
<dd>{{ certificate.issue_date }}</dd> <dd>{{ certificate.issue_date }}</dd>
@@ -36,7 +40,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElLoading, ElMessage } from "element-plus"; import { ElLoading, ElMessage } from "element-plus";
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api"; import { http, type PublicCertificate } from "../api";
@@ -48,6 +52,12 @@ const certificate = ref<PublicCertificate | null>(null);
const message = ref(""); const message = ref("");
const messageType = ref<"success" | "warning" | "error">("success"); const messageType = ref<"success" | "warning" | "error">("success");
const downloading = ref(false); const downloading = ref(false);
const coursePeriodText = computed(() => {
if (!certificate.value) return "";
const start = certificate.value.course_start_date || certificate.value.issue_date;
const end = certificate.value.course_end_date || certificate.value.issue_date;
return `${start}${end}`;
});
function statusText(status: string) { function statusText(status: string) {
return status === "valid" ? "有效" : "已作废"; return status === "valid" ? "有效" : "已作废";

View File

@@ -9,6 +9,8 @@
<el-descriptions-item label="证书编号">{{ certificate.certificate_no }}</el-descriptions-item> <el-descriptions-item label="证书编号">{{ certificate.certificate_no }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ certificate.learner_name }}</el-descriptions-item> <el-descriptions-item label="学员姓名">{{ certificate.learner_name }}</el-descriptions-item>
<el-descriptions-item label="证书名称">{{ certificate.certificate_name }}</el-descriptions-item> <el-descriptions-item label="证书名称">{{ certificate.certificate_name }}</el-descriptions-item>
<el-descriptions-item label="课程时间">{{ coursePeriodText }}</el-descriptions-item>
<el-descriptions-item label="发证日期">{{ certificate.issue_date }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ certificate.issuer_name }}</el-descriptions-item> <el-descriptions-item label="发证单位">{{ certificate.issuer_name }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</section> </section>
@@ -16,7 +18,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api"; import { http, type PublicCertificate } from "../api";
@@ -26,6 +28,12 @@ const token = String(route.params.token);
const certificate = ref<PublicCertificate | null>(null); const certificate = ref<PublicCertificate | null>(null);
const message = ref("正在核验证书信息"); const message = ref("正在核验证书信息");
const messageType = ref<"success" | "warning" | "error" | "info">("info"); const messageType = ref<"success" | "warning" | "error" | "info">("info");
const coursePeriodText = computed(() => {
if (!certificate.value) return "";
const start = certificate.value.course_start_date || certificate.value.issue_date;
const end = certificate.value.course_end_date || certificate.value.issue_date;
return `${start}${end}`;
});
async function loadCertificate() { async function loadCertificate() {
try { try {