Skip to content

Commit 5819340

Browse files
authored
Merge pull request #317 from Pseudo-Lab/feat/certificate-number-format
feat(cert): Update certificate number format and verification ordering
2 parents 4fc74db + bfaab2b commit 5819340

File tree

6 files changed

+191
-13
lines changed

6 files changed

+191
-13
lines changed

โ€Žcert/backend/src/models/certificate.pyโ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class CertificateData(BaseModel):
2828
id: str = Field(..., example="2533a2a2-eed5-81fa-9921-c14d2cd117b7", description="์ˆ˜๋ฃŒ์ฆ ์‹ ์ฒญ ํŽ˜์ด์ง€ ID")
2929
name: str = Field(..., example="ํ™๊ธธ๋™", description="์‹ ์ฒญ์ž ์ด๋ฆ„")
3030
recipient_email: str = Field(..., example="hong@example.com", description="์ˆ˜๋ฃŒ์ž ์ด๋ฉ”์ผ")
31-
certificate_number: str = Field(..., example="CERT-2026DC", description="์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ")
31+
certificate_number: str = Field(..., example="A2025S10_0156", description="์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ")
3232
issue_date: str = Field(..., example="2024-01-15", description="์‹ ์ฒญ ๋‚ ์งœ")
3333
certificate_status: CertificateStatus = Field(..., example=CertificateStatus.PENDING, description="๋ฐœ๊ธ‰ ์—ฌ๋ถ€")
3434
season: int = Field(..., example=10, description="์ฐธ์—ฌ ๊ธฐ์ˆ˜")
@@ -44,7 +44,7 @@ class CertificateResponse(BaseModel):
4444

4545
class CertificateVerifyRequest(BaseModel):
4646
"""์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ํ™•์ธ ์š”์ฒญ ๋ชจ๋ธ"""
47-
certificate_number: str = Field(..., example="CERT-2026DC", description="์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ")
47+
certificate_number: str = Field(..., example="A2025S10_0156", description="์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ")
4848

4949

5050
class CertificateVerifyData(BaseModel):

โ€Žcert/backend/src/services/certificate_service.pyโ€Ž

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import uuid
3+
import re
34
from datetime import datetime
45
from typing import Optional, List
56

@@ -35,6 +36,64 @@ def clear_cache():
3536

3637
class CertificateService:
3738
"""์ˆ˜๋ฃŒ์ฆ ์„œ๋น„์Šค"""
39+
40+
@staticmethod
41+
def _get_study_year(period: dict) -> int:
42+
"""์Šคํ„ฐ๋”” ๊ธฐ๊ฐ„์—์„œ ์—ฐ๋„๋ฅผ ์ถ”์ถœ (์—†์œผ๋ฉด ํ˜„์žฌ ์—ฐ๋„ fallback)"""
43+
for key in ("start", "end"):
44+
raw_date = (period or {}).get(key)
45+
if raw_date:
46+
try:
47+
return datetime.fromisoformat(raw_date).year
48+
except ValueError:
49+
# ISO ํฌ๋งท์ด ์•„๋‹ˆ๋ฉด ์—ฐ๋„๋งŒ ํŒŒ์‹ฑ ์‹œ๋„
50+
year_part = raw_date.split("-", 1)[0]
51+
if year_part.isdigit():
52+
return int(year_part)
53+
return datetime.now().year
54+
55+
@staticmethod
56+
def _build_certificate_number(
57+
study_year: int,
58+
unique_identifier: str,
59+
track_code: str,
60+
season_code: str,
61+
study_id: str,
62+
) -> str:
63+
"""์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ƒ์„ฑ: {์ฝ”๋“œ}{์—ฐ๋„}{๊ธฐ์ˆ˜}-{์Šคํ„ฐ๋””ID}{unique_id}"""
64+
suffix = f"{study_id}{unique_identifier.upper()}"
65+
return f"{track_code}{study_year}{season_code}-{suffix}"
66+
67+
@staticmethod
68+
def _parse_project_code(project_code: str, season: int) -> tuple[str | None, str, str | None]:
69+
"""ํ”„๋กœ์ ํŠธ ์ฝ”๋“œ ํŒŒ์‹ฑ: CODE์—์„œ ํŠธ๋ž™/๊ธฐ์ˆ˜/์Šคํ„ฐ๋””ID ์ถ”์ถœ"""
70+
track_code = None
71+
season_code = f"S{season:02d}"
72+
study_id = None
73+
74+
if not project_code:
75+
return track_code, season_code, study_id
76+
77+
raw_code = project_code.strip()
78+
if "-" in raw_code:
79+
parts = raw_code.split("-", 1)
80+
elif "_" in raw_code:
81+
parts = raw_code.split("_", 1)
82+
else:
83+
parts = [raw_code]
84+
85+
if parts:
86+
season_match = re.match(r"^S(\d+)$", parts[0], re.IGNORECASE)
87+
if season_match:
88+
season_code = f"S{int(season_match.group(1)):02d}"
89+
90+
target = parts[1] if len(parts) > 1 else raw_code
91+
match = re.match(r"^([A-Za-z]+)(\d+)$", target)
92+
if match:
93+
track_code = match.group(1).upper()
94+
study_id = match.group(2)
95+
96+
return track_code, season_code, study_id
3897

3998
@staticmethod
4099
async def create_certificate(certificate_data: dict) -> CertificateResponse:
@@ -133,7 +192,7 @@ async def verify_certificate(file_bytes: bytes) -> dict:
133192
"debug_text": watermark_text
134193
}
135194

136-
# ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ถ”์ถœ (CERT-2026XX ํฌ๋งท ๊ธฐ๋Œ€)
195+
# ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ถ”์ถœ (A2025S10-0156 ํฌ๋งท ๊ธฐ๋Œ€)
137196
cert_number = ""
138197
if "_" in watermark_text:
139198
cert_number = watermark_text.split("_")[1]
@@ -264,7 +323,38 @@ async def _reissue_certificate(
264323
break
265324

266325

267-
existing_cert_number = f"CERT-{datetime.now().year}{participation_info['project_code']}{unique_identifier.upper()}"
326+
study_year = CertificateService._get_study_year(participation_info.get("period", {}))
327+
track_code, season_code, study_id = CertificateService._parse_project_code(
328+
participation_info.get("project_code", ""),
329+
certificate_data["season"],
330+
)
331+
if not track_code:
332+
track_code = "N"
333+
if not study_id:
334+
study_index = await notion_client.get_study_order_index(
335+
season=certificate_data["season"],
336+
course_name=certificate_data["course_name"],
337+
)
338+
if study_index is None:
339+
message = "์Šคํ„ฐ๋”” ์ˆœ์„œ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์—†์–ด ์ˆ˜๋ฃŒ์ฆ์„ ๋ฐœ๊ธ‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
340+
logger.error(
341+
message,
342+
extra={
343+
"season": certificate_data["season"],
344+
"course_name": certificate_data["course_name"],
345+
},
346+
)
347+
raise Exception(message)
348+
else:
349+
study_id = f"{study_index:02d}" if study_index < 100 else str(study_index)
350+
351+
existing_cert_number = CertificateService._build_certificate_number(
352+
study_year=study_year,
353+
unique_identifier=unique_identifier,
354+
track_code=track_code,
355+
season_code=season_code,
356+
study_id=study_id,
357+
)
268358
logger.info(
269359
"์ƒˆ๋กœ์šด ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ƒ์„ฑ",
270360
extra={"certificate_number": existing_cert_number},
@@ -398,7 +488,38 @@ async def _create_new_certificate(
398488
# fallback: Page ID์˜ ๋งˆ์ง€๋ง‰ 5์ž๋ฆฌ
399489
unique_identifier = request_id.replace("-", "")[-5:]
400490

401-
certificate_number = f"CERT-{datetime.now().year}{participation_info['project_code']}{unique_identifier.upper()}"
491+
study_year = CertificateService._get_study_year(participation_info.get("period", {}))
492+
track_code, season_code, study_id = CertificateService._parse_project_code(
493+
participation_info.get("project_code", ""),
494+
certificate_data["season"],
495+
)
496+
if not track_code:
497+
track_code = "N"
498+
if not study_id:
499+
study_index = await notion_client.get_study_order_index(
500+
season=certificate_data["season"],
501+
course_name=certificate_data["course_name"],
502+
)
503+
if study_index is None:
504+
message = "์Šคํ„ฐ๋”” ์ˆœ์„œ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์—†์–ด ์ˆ˜๋ฃŒ์ฆ์„ ๋ฐœ๊ธ‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
505+
logger.error(
506+
message,
507+
extra={
508+
"season": certificate_data["season"],
509+
"course_name": certificate_data["course_name"],
510+
},
511+
)
512+
raise Exception(message)
513+
else:
514+
study_id = f"{study_index:02d}" if study_index < 100 else str(study_index)
515+
516+
certificate_number = CertificateService._build_certificate_number(
517+
study_year=study_year,
518+
unique_identifier=unique_identifier,
519+
track_code=track_code,
520+
season_code=season_code,
521+
study_id=study_id,
522+
)
402523
issue_date = datetime.now().strftime("%Y-%m-%d")
403524

404525
# PDF ์ˆ˜๋ฃŒ์ฆ ์ƒ์„ฑ

โ€Žcert/backend/src/utils/notion_client.pyโ€Ž

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime
2+
import re
23
import json
34
import logging
45
import os
@@ -131,7 +132,7 @@ async def verify_user_participation(
131132
completers = properties.get("์ˆ˜๋ฃŒ์ž", {}).get("rich_text", [])
132133
dropouts = properties.get("์ดํƒˆ์ž", {}).get("multi_select", [])
133134

134-
code_prop = properties.get("์ฝ”๋“œ", {}).get("rich_text", [])
135+
code_prop = properties.get("CODE", {}).get("rich_text", [])
135136
project_code = code_prop[0].get("plain_text", "") if code_prop else ""
136137
if not project_code:
137138
logger.warning(
@@ -684,6 +685,52 @@ def safe_get_text(prop_name: str, default: str = "") -> str:
684685
logger.exception("๋ชจ๋“  ํ”„๋กœ์ ํŠธ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜")
685686
return None
686687

688+
async def get_study_order_index(self, season: int, course_name: str) -> Optional[int]:
689+
"""๊ธฐ์ˆ˜ ๋‚ด ์Šคํ„ฐ๋”” ์ •๋ ฌ ์ˆœ์„œ ๊ธฐ๋ฐ˜ ์ธ๋ฑ์Šค(1-based) ์กฐํšŒ"""
690+
all_projects = await self.get_all_projects()
691+
if not all_projects:
692+
return None
693+
694+
season_projects = [project for project in all_projects if project.season == season]
695+
if not season_projects:
696+
return None
697+
698+
def sort_key(name: str) -> tuple:
699+
normalized = (name or "").strip()
700+
if not normalized:
701+
return (3, "")
702+
703+
first_char = normalized[0]
704+
if first_char.isdigit():
705+
match = re.match(r"^(\d+)", normalized)
706+
number = int(match.group(1)) if match else 0
707+
rest = normalized[match.end():] if match else normalized
708+
return (0, number, rest)
709+
if "A" <= first_char <= "Z" or "a" <= first_char <= "z":
710+
return (1, normalized.casefold())
711+
if "\uac00" <= first_char <= "\ud7a3":
712+
return (2, normalized)
713+
return (3, normalized)
714+
715+
sorted_projects = sorted(
716+
season_projects,
717+
key=lambda project: sort_key(project.name),
718+
)
719+
720+
for index, project in enumerate(sorted_projects, start=1):
721+
if project.name == course_name:
722+
return index
723+
724+
for index, project in enumerate(sorted_projects, start=1):
725+
if course_name and course_name in project.name:
726+
return index
727+
728+
logger.warning(
729+
"์Šคํ„ฐ๋”” ์ˆœ์„œ ์กฐํšŒ ์‹คํŒจ: ํ•ด๋‹น ์ฝ”์Šค๋ช…์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.",
730+
extra={"season": season, "course_name": course_name},
731+
)
732+
return None
733+
687734
async def get_projects_by_season(self) -> Optional[ProjectsBySeasonResponse]:
688735
"""๊ธฐ์ˆ˜๋ณ„๋กœ ํ”„๋กœ์ ํŠธ ๊ทธ๋ฃนํ™”ํ•˜์—ฌ ์กฐํšŒ"""
689736
try:
@@ -887,7 +934,17 @@ async def get_certificate_by_number(self, certificate_number: str) -> Optional[D
887934
"rich_text": {
888935
"equals": certificate_number
889936
}
890-
}
937+
},
938+
"sorts": [
939+
{
940+
"property": "Issue Date",
941+
"direction": "descending"
942+
},
943+
{
944+
"timestamp": "created_time",
945+
"direction": "descending"
946+
}
947+
]
891948
}
892949

893950
try:

โ€Žcert/backend/src/utils/pdf_generator.pyโ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ def __init__(self):
5656
"name": {"x": 299, "y": 1080 - 902 - 65, "font_size": 72, "char_space": -1.2},
5757
}
5858
self.metadata_positions = {
59-
"certificate_number": {"x": 1030, "y": 140, "font_size": 18, "char_space": -0.2},
60-
"issue_date": {"x": 1030, "y": 110, "font_size": 18, "char_space": -0.2},
59+
"certificate_number": {"x": 1030, "y": 160, "font_size": 18, "char_space": -0.2},
60+
"issue_date": {"x": 1030, "y": 130, "font_size": 18, "char_space": -0.2},
6161
}
6262

6363
# ํ…์ŠคํŠธ ์„ค์ •

โ€Žcert/docs/api.mdโ€Ž

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ URL : https://cert.pseudolab-devfactory/api/certs/create
7171
"message": "์ˆ˜๋ฃŒ์ฆ์ด ์„ฑ๊ณต์ ์œผ๋กœ ๋ฐœ๊ธ‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๐Ÿš€\n๋ฉ”์ผํ•จ์„ ํ™•์ธํ•ด๋ณด์„ธ์š”.",
7272
"data": {
7373
"id": 1,
74-
"certificate_number": "CERT-001"
74+
"certificate_number": "A2025S10_0156"
7575
...
7676
}
7777
}
@@ -134,7 +134,7 @@ URL : https://cert.pseudolab-devfactory/api/certs/verify-by-number
134134
#### Request
135135
```json
136136
{
137-
"certificate_number": "CERT-202418"
137+
"certificate_number": "A2025S10_0156"
138138
}
139139
```
140140

@@ -156,6 +156,6 @@ URL : https://cert.pseudolab-devfactory/api/certs/verify-by-number
156156
```json
157157
{
158158
"valid": false,
159-
"message": "์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ(CERT-202618)์— ํ•ด๋‹นํ•˜๋Š” ๋ฐœ๊ธ‰ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
159+
"message": "์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ(A2025S10_0156)์— ํ•ด๋‹นํ•˜๋Š” ๋ฐœ๊ธ‰ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
160160
}
161161
```

โ€Žcert/frontend/src/modules/Home/index.tsxโ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,7 @@ const ExportCertificateForm = () => {
727727
}}
728728
fullWidth
729729
variant="outlined"
730-
placeholder="CERT-XXXXXX"
730+
placeholder="A2025S10_0156"
731731
size="medium"
732732
/>
733733
{/* <Typography variant="caption" sx={{ color: '#6b7280', display: 'block', mt: 1 }}>

0 commit comments

Comments
ย (0)