新增课程起止日期管理

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

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

View File

@@ -44,6 +44,8 @@ def export_certificates(
"证书名称",
"课程名称",
"阶段名称",
"课程开始日期",
"课程结束日期",
"发证日期",
"证书状态",
"PDF状态",
@@ -60,6 +62,8 @@ def export_certificates(
certificate.certificate_name,
certificate.course_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.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.responses import FileResponse, StreamingResponse
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 app.api.deps import require_roles
@@ -31,23 +34,24 @@ router = APIRouter()
COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7"
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
COL_COURSE_START_DATE = "课程开始日期"
COL_COURSE_END_DATE = "课程结束日期"
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
TEMPLATE_HEADERS = [
COL_NAME,
COL_PHONE,
COL_PROJECT,
COL_COURSE_START_DATE,
COL_COURSE_END_DATE,
COL_ISSUE_DATE,
]
REQUIRED_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
REQUIRED_HEADERS = TEMPLATE_HEADERS
@router.get("/template")
def download_template(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
sheet.append(TEMPLATE_HEADERS)
workbook = build_import_template_workbook()
stream_path = data_path("exports") / "certificate-import-template.xlsx"
workbook.save(stream_path)
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)
def upload_import_file(
file: UploadFile = File(...),
@@ -164,6 +178,8 @@ def confirm_import_batch(
for row in rows:
row_data = json.loads(row.raw_json or "{}")
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])
project_code = str(row_data[COL_PROJECT]).strip().upper()
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}"
failed_rows += 1
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:
row.status = "skipped"
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,
course_name=project.default_course_name,
stage_name=project.default_stage_name,
course_start_date=course_start_date,
course_end_date=course_end_date,
issue_date=issue_date,
issuer_name=project.default_issuer_name,
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()
if project_code and project_code not in active_codes:
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]):
errors.append("Issue date format is invalid")
for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE, COL_ISSUE_DATE]:
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
@@ -295,7 +319,14 @@ def get_active_project(db: Session, project_code: str) -> ProjectCourse:
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 (
db.query(Certificate)
.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.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)
.first()
)
@@ -329,6 +362,10 @@ def optional_text(value: object) -> str | None:
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):
return value.date()
if isinstance(value, date):
@@ -339,7 +376,7 @@ def parse_issue_date(value: object) -> date:
return datetime.strptime(text, fmt).date()
except ValueError:
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:
@@ -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"
workbook.save(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,
"course_name": certificate.course_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(),
"issuer_name": certificate.issuer_name,
"status": certificate.status,

View File

@@ -33,6 +33,8 @@ class Certificate(Base):
class_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)
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)
issuer_name: Mapped[str] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(32), default="valid")

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, ValidationInfo, field_validator
class CertificateCreate(BaseModel):
@@ -10,6 +10,8 @@ class CertificateCreate(BaseModel):
class_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)
course_start_date: date
course_end_date: date
issue_date: date
issuer_name: str = Field(min_length=1, max_length=128)
remark: str | None = None
@@ -19,6 +21,14 @@ class CertificateCreate(BaseModel):
def normalize_project_code(cls, value: str) -> str:
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):
id: int
@@ -31,6 +41,8 @@ class CertificateOut(BaseModel):
class_name: str | None
course_name: str | None
stage_name: str | None
course_start_date: date | None
course_end_date: date | None
issue_date: date
issuer_name: 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
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)
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"
@@ -138,17 +140,17 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
x = 185.0 * x_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, 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, 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, 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, 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, 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, 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)
draw_inline_flow(
draw,