diff --git a/backend/alembic/versions/20260813_0006_certificate_course_period.py b/backend/alembic/versions/20260813_0006_certificate_course_period.py new file mode 100644 index 0000000..7456d8e --- /dev/null +++ b/backend/alembic/versions/20260813_0006_certificate_course_period.py @@ -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") diff --git a/backend/app/api/routes/admin_certificates.py b/backend/app/api/routes/admin_certificates.py index af2a2fd..99402df 100644 --- a/backend/app/api/routes/admin_certificates.py +++ b/backend/app/api/routes/admin_certificates.py @@ -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, diff --git a/backend/app/api/routes/admin_exports.py b/backend/app/api/routes/admin_exports.py index 29fcdbc..2884bf6 100644 --- a/backend/app/api/routes/admin_exports.py +++ b/backend/app/api/routes/admin_exports.py @@ -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, diff --git a/backend/app/api/routes/admin_imports.py b/backend/app/api/routes/admin_imports.py index 3c22fb8..af4f189 100644 --- a/backend/app/api/routes/admin_imports.py +++ b/backend/app/api/routes/admin_imports.py @@ -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" diff --git a/backend/app/api/routes/public.py b/backend/app/api/routes/public.py index fe1c550..2133adc 100644 --- a/backend/app/api/routes/public.py +++ b/backend/app/api/routes/public.py @@ -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, diff --git a/backend/app/models/certificate.py b/backend/app/models/certificate.py index 33371e0..84fa984 100644 --- a/backend/app/models/certificate.py +++ b/backend/app/models/certificate.py @@ -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") diff --git a/backend/app/schemas/certificate.py b/backend/app/schemas/certificate.py index 4175d5e..395004a 100644 --- a/backend/app/schemas/certificate.py +++ b/backend/app/schemas/certificate.py @@ -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 diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py index 086e33a..07fd329 100644 --- a/backend/app/services/pdf.py +++ b/backend/app/services/pdf.py @@ -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, diff --git a/backend/tests/test_import_rules.py b/backend/tests/test_import_rules.py index f45a0d1..c790846 100644 --- a/backend/tests/test_import_rules.py +++ b/backend/tests/test_import_rules.py @@ -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="测试单位", + ) diff --git a/docs/decision-log.md b/docs/decision-log.md index c59ed28..2ed31b5 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -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 继续回退到发证日期,避免升级后旧证书无法查看或下载。 diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md index 99b2fb3..86a15f2 100644 --- a/docs/deployment-checklist.md +++ b/docs/deployment-checklist.md @@ -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 导入校验阶段被拦截。 +- 新证书正文显示课程起止日期,底部发证日期仍显示证书签发日期。 - 可导出证书和直达链接。 - 公开查询支持两种方式: - 证书编号查询:必须输入姓名和图形验证码。 diff --git a/docs/mvp-scope.md b/docs/mvp-scope.md index 76be57c..e2606bc 100644 --- a/docs/mvp-scope.md +++ b/docs/mvp-scope.md @@ -39,11 +39,8 @@ 1. 姓名 2. 手机号 3. 项目代码 -4. 证书名称 -5. 课程名称 -6. 阶段名称 -7. 发证日期 -8. 发证单位 -9. 备注 +4. 课程开始日期 +5. 课程结束日期 +6. 发证日期 -证书编号不在 Excel 中填写,由系统确认导入后自动生成。 +三个日期统一使用 `YYYY-MM-DD`,例如 `2026-06-01`,课程结束日期不得早于开始日期。证书名称、课程名称、阶段名称和发证单位从项目配置自动带出;证书编号由系统确认导入后自动生成。 diff --git a/docs/usage-deployment-manual.md b/docs/usage-deployment-manual.md index 331a3fc..0ab265b 100644 --- a/docs/usage-deployment-manual.md +++ b/docs/usage-deployment-manual.md @@ -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。 - 证书导出,包含证书编号和对外直达链接。 diff --git a/docs/正式部署操作手册.md b/docs/正式部署操作手册.md index 5389778..edb6a06 100644 --- a/docs/正式部署操作手册.md +++ b/docs/正式部署操作手册.md @@ -529,12 +529,15 @@ https://你的域名/admin/login 姓名 手机号 项目代码 +课程开始日期 +课程结束日期 发证日期 ``` 检查: - 能下载模板。 +- 模板“填写说明”页包含 `YYYY-MM-DD` 示例,课程结束日期早于开始日期时校验失败。 - 能上传 Excel。 - 错误数据能生成错误报告。 - 校验通过后能确认导入。 diff --git a/docs/阿里云正式部署详细操作手册.md b/docs/阿里云正式部署详细操作手册.md index 61318bf..2345ff6 100644 --- a/docs/阿里云正式部署详细操作手册.md +++ b/docs/阿里云正式部署详细操作手册.md @@ -520,18 +520,21 @@ https://你的域名/admin/login ### 13.5 Excel 导入 -确认模板字段只有: +确认模板字段为: ```text 姓名 手机号 项目代码 +课程开始日期 +课程结束日期 发证日期 ``` 确认: - 能上传 Excel。 +- 模板“填写说明”页包含 `YYYY-MM-DD` 示例,课程结束日期早于开始日期时校验失败。 - 能下载原文件。 - 能删除导入批次。 - 能确认导入。 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ff06395..b6b9240 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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; diff --git a/frontend/src/views/AdminCertificates.vue b/frontend/src/views/AdminCertificates.vue index 9956320..7c4445c 100644 --- a/frontend/src/views/AdminCertificates.vue +++ b/frontend/src/views/AdminCertificates.vue @@ -21,6 +21,13 @@ + + + + + +
证书正文将显示这段课程起止时间,结束日期不能早于开始日期。
+
@@ -64,6 +71,9 @@ + + +