新增课程起止日期管理
This commit is contained in:
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.api.routes.admin_imports import (
|
||||
COL_ISSUE_DATE,
|
||||
COL_COURSE_END_DATE,
|
||||
COL_COURSE_START_DATE,
|
||||
COL_NAME,
|
||||
COL_PHONE,
|
||||
COL_PROJECT,
|
||||
TEMPLATE_HEADERS,
|
||||
build_import_template_workbook,
|
||||
date_is_valid,
|
||||
parse_issue_date,
|
||||
row_errors,
|
||||
)
|
||||
from app.schemas.certificate import CertificateCreate
|
||||
|
||||
|
||||
def test_parse_issue_date_accepts_common_formats():
|
||||
@@ -21,6 +29,8 @@ def test_row_errors_require_project_code_to_exist():
|
||||
COL_NAME: "张三",
|
||||
COL_PHONE: "13800000000",
|
||||
COL_PROJECT: "BAD",
|
||||
COL_COURSE_START_DATE: "2026-05-01",
|
||||
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"})
|
||||
@@ -28,3 +38,51 @@ def test_row_errors_require_project_code_to_exist():
|
||||
|
||||
def test_date_is_valid_rejects_bad_text():
|
||||
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="测试单位",
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
### C009 Excel 导入字段
|
||||
|
||||
- 决定:导入模板只保留姓名、手机号、项目代码、发证日期。
|
||||
- 决定:导入模板保留姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期。
|
||||
- 原因:证书名称、课程名称、阶段名称、发证单位属于项目配置,不应该在每一行 Excel 里重复填写。
|
||||
- 备注:导入备注字段一期移除;批量导入后的备注缺少明确展示和业务使用场景。
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### C012 PDF 批量预生成
|
||||
@@ -107,3 +107,10 @@
|
||||
- 策略:预生成继续使用系统设置里的 `PDF 生成并发限制`,不单独开无限制后台任务。
|
||||
- 原因:大批量学员毕业前先生成缓存,可以降低公开查询高峰的等待时间和 CPU 压力。
|
||||
- 当前限制:任务进度保存在后端进程内,服务重启后只保留已生成的 PDF 缓存,不恢复历史进度。
|
||||
|
||||
### C013 课程起止日期独立建模
|
||||
|
||||
- 决定:证书增加“课程开始日期”和“课程结束日期”两个独立字段,不再用发证日期代替课程时间。
|
||||
- 原因:课程学习周期与证书签发日期含义不同,拆分后可校验时间顺序,也便于后续按开班、结业周期统计。
|
||||
- 导入约定:两个课程日期与发证日期均必填,模板统一推荐 `YYYY-MM-DD`,例如 `2026-06-01`;课程结束日期不得早于开始日期。
|
||||
- 兼容策略:历史证书没有课程日期时,展示和 PDF 继续回退到发证日期,避免升级后旧证书无法查看或下载。
|
||||
|
||||
@@ -38,6 +38,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
||||
- 发证单位名称已确认。
|
||||
- 系统设置里的 `PDF 生成并发限制` 已确认,2 核 4G 建议保持默认 `2`。
|
||||
- Excel 导入模板已下载并试填。
|
||||
- 导入模板中的课程开始日期、课程结束日期和发证日期已统一使用 `YYYY-MM-DD`,例如 `2026-06-01`。
|
||||
- 占位证书模板已确认可临时使用。
|
||||
- 大批量毕业前的操作约定已确认:确认导入后先在“Excel 导入”或“证书管理”里批量预生成 PDF。
|
||||
|
||||
@@ -49,6 +50,8 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
||||
- 可上传 Excel 并确认导入。
|
||||
- 已导入批次可以启动“预生成PDF”,进度弹窗能显示总数、已完成、失败数和并发数。
|
||||
- 证书管理页可按导入批次和 PDF 状态筛选,并能勾选多张证书批量预生成。
|
||||
- 倒序的课程起止日期会在手工新增和 Excel 导入校验阶段被拦截。
|
||||
- 新证书正文显示课程起止日期,底部发证日期仍显示证书签发日期。
|
||||
- 可导出证书和直达链接。
|
||||
- 公开查询支持两种方式:
|
||||
- 证书编号查询:必须输入姓名和图形验证码。
|
||||
|
||||
@@ -39,11 +39,8 @@
|
||||
1. 姓名
|
||||
2. 手机号
|
||||
3. 项目代码
|
||||
4. 证书名称
|
||||
5. 课程名称
|
||||
6. 阶段名称
|
||||
7. 发证日期
|
||||
8. 发证单位
|
||||
9. 备注
|
||||
4. 课程开始日期
|
||||
5. 课程结束日期
|
||||
6. 发证日期
|
||||
|
||||
证书编号不在 Excel 中填写,由系统确认导入后自动生成。
|
||||
三个日期统一使用 `YYYY-MM-DD`,例如 `2026-06-01`,课程结束日期不得早于开始日期。证书名称、课程名称、阶段名称和发证单位从项目配置自动带出;证书编号由系统确认导入后自动生成。
|
||||
|
||||
@@ -42,7 +42,7 @@ reset-local-data.bat
|
||||
2. 进入“项目管理”,维护 `DBY`、`QSX` 等项目代码。项目支持搜索、修改、启用和停用。
|
||||
3. 进入“学员管理”,手动新增、查询、修改或删除学员。
|
||||
4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接、按条件筛选、勾选后批量预生成 PDF 和导出。
|
||||
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
|
||||
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板需要填写姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期;三个日期统一按 `YYYY-MM-DD` 填写,例如 `2026-06-01`。证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
|
||||
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
|
||||
确认导入后,可以在该批次行点击“预生成PDF”,系统会提前为这一批有效证书生成 PDF,并在弹窗里显示总数、已完成、新生成、已缓存、跳过和失败数量。
|
||||
6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`。
|
||||
@@ -143,7 +143,7 @@ docker compose exec backend pytest
|
||||
- 学员手动新增、查询、修改、删除。
|
||||
- 证书新增、查询、后台预览、后台下载、作废、重置直达链接。
|
||||
- 证书按导入批次筛选、按 PDF 状态筛选、批量预生成 PDF。
|
||||
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期。
|
||||
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期,并附有日期格式示例和填写说明。
|
||||
- 导入批次原文件下载和导入批次删除。
|
||||
- 导入后自动生成证书编号、直达链接 token、二维码 token。
|
||||
- 证书导出,包含证书编号和对外直达链接。
|
||||
|
||||
@@ -529,12 +529,15 @@ https://你的域名/admin/login
|
||||
姓名
|
||||
手机号
|
||||
项目代码
|
||||
课程开始日期
|
||||
课程结束日期
|
||||
发证日期
|
||||
```
|
||||
|
||||
检查:
|
||||
|
||||
- 能下载模板。
|
||||
- 模板“填写说明”页包含 `YYYY-MM-DD` 示例,课程结束日期早于开始日期时校验失败。
|
||||
- 能上传 Excel。
|
||||
- 错误数据能生成错误报告。
|
||||
- 校验通过后能确认导入。
|
||||
|
||||
@@ -520,18 +520,21 @@ https://你的域名/admin/login
|
||||
|
||||
### 13.5 Excel 导入
|
||||
|
||||
确认模板字段只有:
|
||||
确认模板字段为:
|
||||
|
||||
```text
|
||||
姓名
|
||||
手机号
|
||||
项目代码
|
||||
课程开始日期
|
||||
课程结束日期
|
||||
发证日期
|
||||
```
|
||||
|
||||
确认:
|
||||
|
||||
- 能上传 Excel。
|
||||
- 模板“填写说明”页包含 `YYYY-MM-DD` 示例,课程结束日期早于开始日期时校验失败。
|
||||
- 能下载原文件。
|
||||
- 能删除导入批次。
|
||||
- 能确认导入。
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface PublicCertificate {
|
||||
project_code: string;
|
||||
course_name: string | null;
|
||||
stage_name: string | null;
|
||||
course_start_date: string | null;
|
||||
course_end_date: string | null;
|
||||
issue_date: string;
|
||||
issuer_name: string;
|
||||
status: string;
|
||||
@@ -70,6 +72,8 @@ export interface AdminCertificate {
|
||||
class_name: string | null;
|
||||
course_name: string | null;
|
||||
stage_name: string | null;
|
||||
course_start_date: string | null;
|
||||
course_end_date: string | null;
|
||||
issue_date: string;
|
||||
issuer_name: string;
|
||||
status: string;
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
<el-form-item label="发证日期">
|
||||
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</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-input v-model.trim="form.course_name" />
|
||||
</el-form-item>
|
||||
@@ -64,6 +71,9 @@
|
||||
<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="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 label="PDF" width="92">
|
||||
<template #default="{ row }">
|
||||
@@ -96,6 +106,7 @@
|
||||
<el-descriptions-item label="项目/课程/阶段">
|
||||
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
|
||||
</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.issuer_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公开链接">
|
||||
@@ -160,6 +171,8 @@ const form = reactive({
|
||||
project_code: "",
|
||||
course_name: "",
|
||||
stage_name: "",
|
||||
course_start_date: "",
|
||||
course_end_date: "",
|
||||
issue_date: "",
|
||||
issuer_name: "",
|
||||
remark: "",
|
||||
@@ -175,6 +188,12 @@ function pdfStatusText(status: string) {
|
||||
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 || ""));
|
||||
|
||||
async function loadProjects() {
|
||||
@@ -204,8 +223,12 @@ function handleSelectionChange(rows: AdminCertificate[]) {
|
||||
}
|
||||
|
||||
async function createCertificate() {
|
||||
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) {
|
||||
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位");
|
||||
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date || !form.issuer_name) {
|
||||
ElMessage.warning("请填写学员ID、项目、课程起止日期、发证日期和发证单位");
|
||||
return;
|
||||
}
|
||||
if (form.course_end_date < form.course_start_date) {
|
||||
ElMessage.warning("课程结束日期不能早于课程开始日期");
|
||||
return;
|
||||
}
|
||||
await http.post("/admin/certificates", {
|
||||
@@ -213,6 +236,8 @@ async function createCertificate() {
|
||||
project_code: form.project_code,
|
||||
course_name: form.course_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,
|
||||
issuer_name: form.issuer_name,
|
||||
remark: form.remark || null,
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<el-button @click="downloadTemplate">下载模板</el-button>
|
||||
</header>
|
||||
|
||||
<el-alert
|
||||
class="import-tip"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
|
||||
/>
|
||||
|
||||
<el-upload
|
||||
class="upload"
|
||||
drag
|
||||
@@ -224,9 +231,14 @@ onUnmounted(stopPregenerationPolling);
|
||||
}
|
||||
|
||||
.upload {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-tip {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 36px;
|
||||
color: #208a87;
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<dt>证书编号</dt>
|
||||
<dd>{{ certificate.certificate_no }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>课程时间</dt>
|
||||
<dd>{{ coursePeriodText }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发证日期</dt>
|
||||
<dd>{{ certificate.issue_date }}</dd>
|
||||
@@ -36,7 +40,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElLoading, ElMessage } from "element-plus";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { http, type PublicCertificate } from "../api";
|
||||
@@ -48,6 +52,12 @@ const certificate = ref<PublicCertificate | null>(null);
|
||||
const message = ref("");
|
||||
const messageType = ref<"success" | "warning" | "error">("success");
|
||||
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) {
|
||||
return status === "valid" ? "有效" : "已作废";
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<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.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>
|
||||
</section>
|
||||
@@ -16,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { http, type PublicCertificate } from "../api";
|
||||
@@ -26,6 +28,12 @@ const token = String(route.params.token);
|
||||
const certificate = ref<PublicCertificate | null>(null);
|
||||
const message = ref("正在核验证书信息");
|
||||
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() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user