팀 휴가 및 공휴일 커버리지 플래너
Wikiprompt, 무료 프롬프트 백과사전에서
팀 휴가 및 공휴일 커버리지 플래너 팀 휴가 계획을 위한 완전한 AI 스킬로, 최소 인력 유지, 커버리지 규칙, 공정한 우선순위, 대체 제안, 그리고 Python 플래너 스크립트를 포함합니다.
프롬프트 내용저장
🌐
---
name: team-leave-and-holiday-coverage-planner
description: 팀의 최소 인력 충원을 기준으로 팀 휴가를 계획합니다. 특히 연휴 기간에 모든 근무일을 역할별 커버리지 규칙(한 사람이 한 슬롯을 담당)에 따라 확인하고, 공정한 우선순위에 따라 승인 가능한 요청을 결정하며, 공백 없는 대체 날짜를 제안하고, 블랙아웃 기간, 파트타임 근무일 및 반일을 존중하며, 연차 수당, 이월 및 성수기 휴가를 추적합니다. --self-test가 포함된 오프라인 표준 라이브러리 Python 플래너를 포함합니다.
---
# 팀 휴가 및 연휴 커버리지 플래너
팀 리드, 지원 및 운영 관리자, 클리닉 및 상점 주인이 12월의 질문에 답할 수 있도록 돕습니다: 누가, 언제 쉴 수 있고, 데스크를 비우지 않으면서도 공정한가? 휴가 스프레드시트는 누가 없는지는 보여주지만, 남은 사람들이 실제로 역할을 커버할 수 있는지는 보여주지 않습니다(리드이자 유일한 티어-2 엔지니어인 사람은 같은 날 두 역할을 모두 할 수 없습니다). 팀, 커버리지 규칙 및 휴가 요청을 일별 점검으로 전환하고, 명시된 우선순위에 따라 요청을 결정하며, 무조건 거절하는 대신 대안을 제시하고, HR 대화가 되기 전에 수당 및 공정성 문제를 표시합니다.
## 이 스킬의 파일
- `scripts/leave_cover.py` - 팀 JSON(구성원, 역할, 파트타임 근무일, 수당, 커버리지 최소 인원, 휴일, 오버라이드, 블랙아웃 및 성수기 일) 및 휴가 CSV를 읽습니다. 각 근무일을 한 사람당 한 슬롯 매칭으로 확인하고, 승인된 휴가로 인한 공백을 찾고, 미결 요청을 결정하며(작년 성수기 근무자 우선, 그 다음 선착순), 가장 가까운 공백 없는 날짜 또는 더 짧은 여행을 제안하고, 달력 그리드, 수당 테이블 및 LV01-LV07 결과를 보고합니다(Python 3.8+ 표준 라이브러리만 사용).
- `references/coverage-rules.md` - 역할별 최소 인원 설정 방법, 한가한 날의 오버라이드, 파트타임 근로자, 반일, 당직 및 매칭이 수행하는 작업
- `references/fair-leave-policy.md` - 우선순위 규칙, 블랙아웃 기간, 수당 및 이월, 거절을 잘하는 방법, 각 결과 코드의 의미
- `templates/leave-plan-brief.md` - 팀에 보낼 계획
- `examples/support-team.json` - 크리스마스와 새해 연휴 기간의 7인 고객 지원 팀: 리드, 티어 2, 당직, 파트타임 근로자, 휴일, 12월 24일 및 31일의 축소 커버리지, 청구 실행 블랙아웃 및 이월 한도
- `examples/support-leave.csv` - 승인된 휴가 3건 및 요청된 휴가 7건(반일 포함)
- `examples/support-plan.md` - 해당 파일에 대한 실제 플래너 출력
## 워크플로우
### 1. 입력 수집
다음을 요청합니다: 기간(전체 연휴 기간 및 양쪽으로 일주일 포함), 각 구성원의 역할 및 근무일이 포함된 팀, 수당 및 이번 휴가 연도에 이미 사용한 일수, 팀이 문을 닫는 공휴일(또는 영업하는 경우 축소 커버리지), 역할별 및 총 최소 커버리지, 최소 기준이 낮은 한가한 날, 블랙아웃 기간(출시, 재고 조사, 청구 실행, 감사) 및 사람들이 경쟁하는 성수기 일. 그 다음 휴가: 승인됨, 요청됨(제출 날짜 포함), 교육, 알려진 육아 또는 의료 휴가. 팀이 우선순위에 대해 합의한 사항을 묻습니다. 없으면 "작년 성수기 근무자 우선, 그 다음 선착순"을 제안합니다.
### 2. 플래너 실행
```
python3 scripts/leave_cover.py examples/support-team.json examples/support-leave.csv
python3 scripts/leave_cover.py team.json leave.csv --json
python3 scripts/leave_cover.py --self-test
```
종료 코드 0 = 승인된 휴가가 커버리지를 유지하고 아무도 수당을 초과하지 않음, 1 = 승인된 휴가가 이미 커버리지를 위반하거나 수당을 초과함, 2 = 잘못된 입력(메시지가 해당 줄을 명명함). 셸이 없으면 달력을 수동으로 작성합니다: 사람당 한 행, 근무일당 한 열, 그런 다음 각 날짜에 대해 근무 가능한 사람을 역할 슬롯에 한 사람당 한 슬롯으로 할당하고 부족분을 표시합니다.
### 3. 결과 읽기
- 이미 승인된 휴가로 인한 공백(LV01)이 먼저입니다: 지금 수정이 필요합니다(임시 커버, 교대, 대행사 또는 당직 약속). 새 규칙이 아닙니다.
- 수당 문제(LV02): 승인된 휴가가 수당을 초과하면 HR에 이월 또는 무급 일수를 확인해야 합니다.
- 요청: 승인으로 표시된 요청을 승인합니다. 충돌 및 블랙아웃의 경우 거절이 아닌 대안을 해당 직원에게 제시합니다(LV04).
- 공정성(LV05) 및 성수기 휴가: 아무도 2년 연속 최고의 날을 받지 않고, 작년에 근무한 사람이 거절당하지 않는지 확인합니다.
- 취약성: 여유 인력이 없는 역할(LV03) 및 여유가 전혀 없는 날(LV07)에는 지명된 백업이 필요합니다.
- 사용하지 않으면 소멸(LV06): 일수를 잃을 사람들은 한가한 주에 예약해야 합니다.
### 4. 조정 및 재실행
동일한 파일로 명백한 수정을 시도합니다: 교차 훈련된 백업(누군가에게 역할 추가), 한가한 날의 더 낮은 최소 기준(오버라이드), 두 사람 간의 교대, 또는 긴 여행 분할. 승인된 휴가에 공백이 없고 모든 미결 요청에 결정 또는 구체적인 대안이 있을 때까지 다시 실행합니다.
### 5. 전달
`templates/leave-plan-brief.md`를 작성합니다: 사용된 규칙, 요청별 결정 및 이유와 대안, 달력, 인력이 부족한 날의 커버리지 약속 및 수당 메모. 계획을 게시하기 전에 요청이 승인되지 않은 사람들과 이야기하십시오.
## 예시 결과
지원 팀 예시에서 Omar의 승인된 여행은 이미 그를 25일 수당보다 3일 초과하게 만듭니다(LV02, 높음: HR이 이월 또는 무급 일수를 확인해야 함). Sam은 작년 크리스마스에 근무했으므로 12월 24일-1월 4일 요청이 먼저 결정되어 승인됩니다. Jonas(12월 21-23일)와 Priya의 반일 및 12월 21-23일도 승인됩니다. Ade의 12월 29-31일은 12월 29일과 30일에 티어 2 및 총 커버리지가 부족하게 만들 것입니다. 플래너는 대신 12월 16-18일을 제안합니다. Lena의 12월 30일-1월 8일은 1월 5-6일 청구 실행 블랙아웃을 넘어서며, 공백 없이 승인될 수 있는 것은 12월 31일뿐입니다. Jonas의 두 번째 여행은 4일이 남은 상태에서 5일이 필요합니다. Lena는 5일 이월 한도로 10일을 잃게 되며, 12월 21-23일과 29일에는 여유가 전혀 없습니다.
## 규칙
- 헤드카운트만이 아닌 한 사람당 한 슬롯으로 역할별 커버리지를 확인합니다.
- 모든 사람에게 하나의 명시된 우선순위 규칙을 적용하고 순서를 보여줍니다. 가장 크게 요청한 사람이 결정해서는 안 됩니다.
- 항상 "이번에는 안 됨"에 대체 날짜, 더 짧은 여행 또는 교대를 짝지어 제시합니다.
- 병가, 육아 및 기타 보호 휴가는 고정으로 취급합니다: 이동을 요청하지 말고 의료 세부 정보를 계획에 포함하지 마십시오.
- 수당, 이월 및 무급 휴가는 계약 및 현지 법률을 따릅니다. 결정하지 말고 HR에 플래그를 지정하십시오.
- 계획은 필요한 팀 및 관리자에게만 공유하십시오.
## 커버리지 규칙
### 최소 인원
- **역할**별 최소 인원을 설정합니다(커버해야 할 것: 리드/승인자, 티어 2, 당직, 약사, 키 홀더, 지게차 면허) 및 **총** 인원(업무량에 충분한 인원).
- 숫자는 습관이 아닌 수요에 기반합니다: 일일 티켓/통화/주문을 한 사람이 처리하는 양으로 나누고, 법률 또는 계약상 반드시 있어야 하는 역할을 더합니다.
- 한가한 날(12월 24일 및 31일, 많은 B2B 팀의 연휴 사이 주)은 일반적으로 더 낮은 최소 기준이 정당합니다: 전체 기간을 낮추는 대신 해당 날짜에 `coverage_overrides`를 사용하십시오.
- 영업이 중단된 날은 `holidays`에 넣습니다: 커버리지 불필요, 수당 차감 없음.
### 한 사람, 한 슬롯
여러 역할을 가진 사람은 하루에 한 슬롯만 채웁니다. 플래너는 이분 매칭으로 사람들을 슬롯에 할당하므로 가능한 배열이 있으면 찾습니다:
- Maya(리드, 티어 2) + Lena(티어 1, 티어 2)는 리드 1 + 티어 2 1을 커버할 수 있지만, 리드 1 + 티어 2 2는 불가능합니다.
- 부족분은 역할별로 보고됩니다. "총" 부족은 전체적으로 너무 적은 사람을 의미합니다.
### 사람
- `days`: 파트타임 근로자의 근무 요일. 근무하지 않는 날의 휴가는 수당을 소모하지 않으며 커버리지에 영향을 미치지 않습니다.
- 반일(`half_day=yes`)은 수당 0.5일을 소모합니다. 플래너는 그날 해당 사람을 커버리지에 포함하지 않습니다(보수적). 오전만 결근해도 오후 교대를 커버할 수 있다면 브리핑에 명시하십시오.
- 교육, 병가, 육아 및 무급 휴가는 휴가처럼 커버리지를 줄이지만 수당에는 포함되지 않습니다.
### 당직 및 원격 커버리지
- 누군가 휴가 중 집에서 당직을 커버하는 경우 해당 역할에 대해 휴가로 표시하지 마십시오. 명시적으로 동의하고 정책에 따라 지불하십시오. 플래너는 휴가를 완전히 쉬는 것으로 취급합니다.
- 외부 또는 대행사 백업은 해당 역할만 있고 수당이 없는 사람으로 추가할 수 있습니다.
### 여유
- 여유가 전혀 없는 날(정확히 최소 인원 근무)은 병가 하루로 무너집니다. 기간 시작 전에 각 날짜에 대한 백업을 지명하십시오.
- 최소 + 1명만 보유한 역할은 두 명의 결근을 견딜 수 없습니다. 새해에 교차 훈련을 계획하십시오.
## 공정한 휴가 결정
### 우선순위 규칙(요청이 들어오기 전에 명시)
일반적이고 방어 가능한 순서:
1. 이미 승인된 휴가가 우선합니다(본인이 변경에 동의하지 않는 한).
2. 보호 휴가(육아, 의료, 법정)는 고정입니다.
3. 성수기 날의 경우: 작년 성수기에 근무한 사람이 먼저입니다.
4. 그 다음 제출 날짜별 선착순.
5. 동점: 순환(마지막 동점에서 진 사람이 이번에 이깁니다).
성수기 날에 연공서열만 사용하는 규칙은 피하십시오. 신입 직원에게 불공평하게 느껴지고 종종 좋은 인재를 잃습니다.
### 블랙아웃 기간
- 짧게 유지하고 실제 이벤트(출시, 청구 실행, 재고 조사, 감사, 학원 사업의 학기 시작)와 연결하십시오. 일찍 공지하십시오.
- 블랙아웃에 걸리는 요청은 자동으로 거절되지 않습니다: 플래너는 가장 가까운 공백 없는 날짜 또는 블랙아웃 전의 가장 긴 부분을 제공합니다. 예외는 지명된 승인자가 필요합니다.
### 수당 및 이월
- 잔여 = 수당 - 기간 전 사용 - 승인 - 권장.
- 수당을 초과하는 승인된 휴가(LV02 높음)는 HR 문제입니다: 작년 이월, 추가 일수 구매 또는 무급 휴가.
- 이월 한도는 연말에 예약되지 않은 일수가 소멸됨을 의미합니다(LV06): 한가한 주를 예약하도록 알리십시오. 일부 국가에서는 법정 최소 휴가가 단순히 만료될 수 없으므로 현지 규칙을 확인하십시오.
### 거절을 잘하는 방법
- 규칙과 특정 공백을 설명하십시오("12월 29일과 30일에 티어 2가 한 명 부족합니다").
- 플래너가 찾은 대안 또는 지명된 동료와의 교대 또는 더 짧은 여행을 제안하십시오.
- 일찍 결정하십시오(연휴 최소 4-6주 전) 그래야 사람들이 여행을 예약할 수 있습니다.
- 결정과 이유를 기록하십시오. 내년 우선순위는 그것에 달려 있습니다.
### 결과 코드 (leave_cover.py)
| 코드 | 심각도 | 의미 |
|---|---|---|
| LV01 | 높음 | 승인된 휴가가 이미 특정 날짜에 역할 또는 총 인원을 부족하게 만듦 |
| LV02 | 높음 / 중간 | 승인된 휴가가 수당 초과 / 요청이 남은 일수보다 더 필요 |
| LV03 | 중간 / 낮음 | 역할을 최소 인원보다 많지 않은 사람이 보유 / 예비 인력 1명만 있음 |
| LV04 | 중간 | 요청이 커버리지 또는 블랙아웃과 충돌, 제공된 대안 포함 |
| LV05 | 중간 | 작년 성수기에 근무한 사람이 거절당하는 동안 다른 사람이 성수기 날을 받음 |
| LV06 | 낮음 | 이월 한도 이상으로 남은 일수가 소멸됨 |
| LV07 | 정보 | 계획 후 여유가 전혀 없는 날 |
## 연휴 휴가 계획 템플릿
# 연휴 휴가 계획: <팀>, <기간>
**커버리지 규칙:** <리드 1, 티어 2 2 (12/24, 12/31은 1), 당직 1, 최소 4명> **휴무:** <12/25, 12/28, 1/1>
**사용된 우선순위:** <작년 성수기 근무자 우선, 그 다음 선착순>
**블랙아웃:** <1/5-6 청구 실행>
## 결정
| 사람 | 요청 기간 | 결정 | 이유 | 제공된 대안 |
|---|---|---|---|---|
| <Sam> | <12/24 - 1/4> | <승인> | <작년 크리스마스 근무> | - |
| <Ade> | <12/29-31> | <이 날짜는 안 됨> | <12/29-30 티어 2 부족> | <12/16-18, 또는 ...와 교대> |
## 달력
```
<플래너 그리드 붙여넣기>
```
## 인력 부족일 및 백업
| 날짜 | 부족 이유 | 백업 |
|---|---|---|
| <12/21-23> | <정확히 4명 근무> | <집에서 당직: ...> |
## 수당 메모 (HR용)
- <Omar: 승인된 여행이 수당보다 3일 초과 - 이월 / 무급 확인>
- <Lena: 15일 남음, 이월 한도 5 - 2월 예약 제안>
## 다음 단계
- <게시 전에 Ade, Lena, Jonas와 상담>
- <...에 계획 게시, 변경은 ...에 교대 요청>
- <Q1에 두 번째 리드 백업 교차 훈련>
## 예시 팀 JSON
{
"team": "Northwind Customer Support",
"period": "2026-12-14..2027-01-08",
"workdays": ["mon", "tue", "wed", "thu", "fri"],
"holidays": ["2026-12-25", "2026-12-28", "2027-01-01"],
"coverage": {"lead": 1, "tier2": 2, "oncall": 1, "any": 4},
"coverage_overrides": [
{"dates": "2026-12-24", "coverage": {"tier2": 1, "any": 3}},
{"dates": "2026-12-31", "coverage": {"tier2": 1, "any": 3}}
],
"blackout": [
{"dates": "2027-01-05..2027-01-06", "reason": "January billing run, all hands"}
],
"peak_days": "2026-12-21..2027-01-01",
"carryover_max": 5,
"people": [
{"name": "Maya", "roles": ["lead", "tier2"], "allowance": 25, "taken": 17, "worked_peak_last_year": true},
{"name": "Ade", "roles": ["lead", "tier2"], "allowance": 25, "taken": 19},
{"name": "Omar", "roles": ["tier2", "oncall"], "allowance": 25, "taken": 20},
{"name": "Lena", "roles": ["tier1", "tier2"], "allowance": 25, "taken": 10},
{"name": "Jonas", "roles": ["tier1", "oncall"], "allowance": 25, "taken": 18},
{"name": "Sam", "roles": ["tier1", "oncall"], "allowance": 25, "taken": 15, "worked_peak_last_year": true},
{"name": "Priya", "roles": ["tier1"], "days": ["mon", "wed", "thu"], "allowance": 15, "taken": 9}
]
}
## 예시 휴가 CSV
person,start,end,type,status,submitted,half_day,notes
Maya,2026-12-21,2026-12-24,vacation,approved,2026-09-30,,family visit
Omar,2026-12-29,2027-01-08,vacation,approved,2026-10-02,,"flights booked, Lagos"
Lena,2026-12-14,2026-12-15,training,approved,2026-11-01,,Zendesk admin course
Jonas,2026-12-21,2026-12-23,vacation,requested,2026-10-20,,
Ade,2026-12-29,2026-12-31,vacation,requested,2026-10-25,,
Sam,2026-12-24,2027-01-04,vacation,requested,2026-11-05,,"skiing, worked last Christmas"
Lena,2026-12-30,2027-01-08,vacation,requested,2026-11-12,,
Priya,2026-12-16,2026-12-16,vacation,requested,2026-11-20,yes,school nativity play
Jonas,2027-01-04,2027-01-08,vacation,requested,2026-11-25,,
Priya,2026-12-21,2026-12-23,vacation,requested,2026-11-28,,
## 예시 플래너 출력
# Leave coverage plan: Northwind Customer Support (2026-12-14 to 2027-01-08)
Minimum cover per workday: any 4, lead 1, oncall 1, tier2 2. Holidays (closed): 25 Dec, 28 Dec, 01 Jan.
**Verdict: over allowance on approved leave** - 4 of 7 open requests can be approved without a gap; 3 need a decision.
## Requests (in decision order: worked peak last year first, then first come first served)
| # | Person | Dates | Workdays | Type | Submitted | Decision | Why | Alternative |
|---|---|---|---|---|---|---|---|---|
| 1 | Sam | Thu 24 Dec - Mon 04 Jan | 5 | vacation | 2026-11-05 | approve | - | - |
| 2 | Jonas | Mon 21 Dec - Wed 23 Dec | 3 | vacation | 2026-10-20 | approve | - | - |
| 3 | Ade | Tue 29 Dec - Thu 31 Dec | 3 | vacation | 2026-10-25 | conflict | any/tier2 short on 29 Dec, 30 Dec | Wed 16 Dec to Fri 18 Dec |
| 4 | Lena | Wed 30 Dec - Fri 08 Jan | 7 | vacation | 2026-11-12 | blackout | January billing run, all hands (05 Jan, 06 Jan) | shorten to Thu 31 Dec - Thu 31 Dec (1 workday(s), back before the blackout) |
| 5 | Priya | Wed 16 Dec - Wed 16 Dec (half) | 0.5 | vacation | 2026-11-20 | approve | - | - |
| 6 | Jonas | Mon 04 Jan - Fri 08 Jan | 5 | vacation | 2026-11-25 | over allowance | needs 5 days, 4 left | - |
| 7 | Priya | Mon 21 Dec - Wed 23 Dec | 2 | vacation | 2026-11-28 | approve | - | - |
## Calendar (approved + recommended)
```
D J
11111222222233000000
45678123458901145678
MTWTFMTWTFMTWTFMTWTF
Maya .....VVVVHH...H.....
Ade .........HHxxxH.....
Omar .........HHVVVHVVVVV
Lena TT.......HH.xxHxxxxx
Jonas .....vvv.HH...Hxxxxx
Sam ........vHHvvvHv....
Priya .-h.-v-v.HH-..H.-..-
cover any +++++++++ +++ +++++
cover lead +++++++++ +++ +++++
cover oncall +++++++++ +++ +++++
cover tier2 +++++++++ +++ +++++
```
V/T/S/P/U/O approved vacation/training/sick/parental/unpaid/other, lowercase = request recommended for approval, h = half day, x = request not approved, H = holiday, - = not a working day, + / ! = role covered / short.
## Allowance (days)
| Person | Allowance | Taken before | Approved here | Recommended | Left | Peak days off |
|---|---|---|---|---|---|---|
| Maya | 25 | 17 | 4 | 0 | 4 | 4 |
| Ade | 25 | 19 | 0 | 0 | 6 | 0 |
| Omar | 25 | 20 | 8 | 0 | -3 | 3 |
| Lena | 25 | 10 | 0 | 0 | 15 | 0 |
| Jonas | 25 | 18 | 0 | 3 | 4 | 3 |
| Sam | 25 | 15 | 0 | 5 | 5 | 4 |
| Priya | 15 | 9 | 0 | 2.5 | 3.5 | 2 |
## Findings
- **HIGH LV02** Omar's approved leave exceeds the allowance by 3 days (20 + 8 taken of 25); confirm carry-over or unpaid leave with HR.
- **MEDIUM LV02** Jonas 04 Jan-08 Jan: needs 5 days, 4 left; approve a shorter trip or agree unpaid days
- **MEDIUM LV04** Ade 29 Dec-31 Dec: conflict (any/tier2 short on 29 Dec, 30 Dec); offer Wed 16 Dec to Fri 18 Dec
- **MEDIUM LV04** Lena 30 Dec-08 Jan: blackout (January billing run, all hands (05 Jan, 06 Jan)); offer shorten to Thu 31 Dec - Thu 31 Dec (1 workday(s), back before the blackout)
- **LOW LV03** lead has one spare person (Maya, Ade for a minimum of 1); two absences at once break it.
- **LOW LV06** Ade will still have 6 days left (carry-over limit 5); 1 day(s) are lost unless booked.
- **LOW LV06** Lena will still have 15 days left (carry-over limit 5); 10 day(s) are lost unless booked.
- **INFO LV07** No slack on 4 day(s) (21 Dec, 22 Dec, 23 Dec, 29 Dec): one sick day breaks coverage; line up a backup.
## Python 플래너 스크립트
```python
#!/usr/bin/env python3
"""Team leave and holiday coverage planner (Python 3.8+ standard library only).
Checks a team's leave requests against minimum staffing rules day by day, decides which
requests can be approved without leaving a gap, suggests alternative dates for the rest, and
tracks allowances and who gets the peak holiday days.
Team JSON:
{
"team": "Support Desk", "period": "2026-12-14..2027-01-08",
"workdays": ["mon", "tue", "wed", "thu", "fri"],
"holidays": ["2026-12-25", "2026-12-28", "2027-01-01"], (office closed, no coverage needed)
"coverage": {"lead": 1, "tier2": 2, "oncall": 1, "any": 4}, ("any" = total people working)
"coverage_overrides": [{"dates": "2026-12-24", "coverage": {"tier2": 1, "any": 3}}],
"blackout": [{"dates": "2027-01-05..2027-01-06", "reason": "billing run"}],
"peak_days": "2026-12-21..2027-01-01",
"people": [
{"name": "Maya", "roles": ["lead", "tier2"], "allowance": 25, "taken": 17, "worked_peak_last_year": true},
{"name": "Priya", "roles": ["tier1"], "days": ["mon", "wed", "thu"], "allowance": 15, "taken": 9}
]
}
Each working person fills ONE role slot per day (a lead who is also tier2 counts once), solved
as a bipartite matching.
Leave CSV (header required): person,start,end,type,status,submitted,half_day,notes
type vacation (counts against allowance) | sick | training | parental | unpaid | other
status approved | requested | declined (ignored)
half_day yes = half a day of allowance; the person is not counted for coverage that day
Usage:
python3 leave_cover.py team.json leave.csv [--json]
python3 leave_cover.py --self-test
Exit codes: 0 = approved leave keeps coverage and nobody is over allowance, 1 = approved leave
already breaks coverage or exceeds an allowance, 2 = bad input.
"""
import argparse
import csv
import datetime as dt
import json
import sys
from collections import OrderedDict, defaultdict
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
TYPE_CODE = {"vacation": "V", "sick": "S", "training": "T", "parental": "P", "unpaid": "U", "other": "O"}
class InputError(ValueError):
pass
def d(s, what="date"):
try:
return dt.date.fromisoformat(str(s).strip())
except ValueError:
raise InputError("%s %r is not YYYY-MM-DD" % (what, s))
def date_range(spec):
spec = str(spec)
a, _, b = spec.partition("..")
a, b = d(a), d(b or a)
if b < a:
raise InputError("range %r ends before it starts" % spec)
return [a + dt.timedelta(days=i) for i in range((b - a).days + 1)]
# ----------------------------------------------------------------------------- inputs
def load_team(cfg):
if "period" not in cfg or not cfg.get("people"):
raise InputError("team JSON needs 'period' and 'people'")
t = dict(cfg)
t["days"] = date_range(cfg["period"])
t["workdays"] = [w.lower()[:3] for w in cfg.get("workdays", DAYS[:5])]
t["holidays"] = {d(h, "holiday") for h in cfg.get("holidays", [])}
t["coverage"] = {k: int(v) for k, v in (cfg.get("coverage") or {}).items()}
over = {}
for o in cfg.get("coverage_overrides", []):
for day in date_range(o["dates"]):
over[day] = {k: int(v) for k, v in o["coverage"].items()}
t["overrides"] = over
t["blackout"] = [(set(date_range(b["dates"])), b.get("reason", "blackout")) for b in cfg.get("blackout", [])]
t["peak"] = set(date_range(cfg["peak_days"])) if cfg.get("peak_days") else set()
people = OrderedDict()
for p in cfg["people"]:
if "name" not in p:
raise InputError("every person needs a name")
q = dict(p)
q["roles"] = list(p.get("roles", []))
q["days"] = [w.lower()[:3] for w in p.get("days", t["workdays"])]
q["allowance"] = float(p.get("allowance", 0))
q["taken"] = float(p.get("taken", 0))
q["carryover_max"] = p.get("carryover_max", cfg.get("carryover_max"))
people[p["name"]] = q
t["people"] = people
roles = {r for p in people.values() for r in p["roles"]}
for r in list(t["coverage"]) + [r for o in over.values() for r in o]:
if r != "any" and r not in roles:
raise InputError("coverage role %r is not held by anyone" % r)
return t
def load_leave(path, team):
out = []
with open(path, newline="", encoding="utf-8") as f:
rows = [r for r in f if r.strip() and not r.lstrip().startswith("#")]
for n, row in enumerate(csv.DictReader(rows), 2):
name = (row.get("person") or "").strip()
if name not in team["people"]:
raise InputError("leave line %d: unknown person %r" % (n, name))
typ = (row.get("type") or "vacation").strip().lower()
if typ not in TYPE_CODE:
raise InputError("leave line %d: type must be one of %s" % (n, ", ".join(TYPE_CODE)))
status = (row.get("status") or "requested").strip().lower()
if status not in ("approved", "requested", "declined"):
raise InputError("leave line %d: status must be approved, requested or declined" % n)
start, end = d(row.get("start"), "start"), d(row.get("end") or row.get("start"), "end")
if end < start:
raise InputError("leave line %d: end before start" % n)
out.append({"id": n, "person": name, "start": start, "end": end, "type": typ, "status": status,
"submitted": (row.get("submitted") or "").strip() or "9999-12-31",
"half": (row.get("half_day") or "").strip().lower() in ("yes", "y", "true", "1"),
"notes": (row.get("notes") or "").strip()})
return out
# ----------------------------------------------------------------------------- core
def is_workday(team, day):
return DAYS[day.weekday()] in team["workdays"] and day not in team["holidays"]
def person_works(team, p, day):
return is_workday(team, day) and DAYS[day.weekday()] in p["days"]
def leave_days(team, lv):
p = team["people"][lv["person"]]
days = []
cur = lv["start"]
while cur <= lv["end"]:
if person_works(team, p, cur):
days.append(cur)
cur += dt.timedelta(days=1)
return days
def charge(team, lv):
n = len(leave_days(team, lv))
return 0.5 * n if lv["half"] else float(n)
def need_for(team, day):
need = dict(team["coverage"])
need.update(team["overrides"].get(day, {}))
return need
def shortfall(team, day, off):
"""Return {role: missing} for one day given the set of people off."""
if not is_workday(team, day):
return {}
avail = [p for n, p in team["people"].items() if n not in off and person_works(team, p, day)]
need = need_for(team, day)
slots = [r for r, k in sorted(need.items()) if r != "any" for _ in range(k)]
match = {} # slot index -> person name
def try_assign(pi, seen):
for si, role in enumerate(slots):
if role in avail[pi]["roles"] and si not in seen:
seen.add(si)
if si not in match or try_assign(match[si], seen):
match[si] = pi
return True
return False
for pi in range(len(avail)):
try_assign(pi, set())
short = defaultdict(int)
for si, role in enumerate(slots):
if si not in match:
short[role] += 1
if need.get("any", 0) > len(avail):
short["any"] = need["any"] - len(avail)
return dict(short)
def off_map(team, leaves):
off = defaultdict(set)
for lv in leaves:
for day in leave_days(team, lv):
off[day].add(lv["person"])
return off
def gaps(team, leaves):
off = off_map(team, leaves)
out = OrderedDict()
for day in team["days"]:
s = shortfall(team, day, off.get(day, set()))
if s:
out[day] = s
return out
def total(g):
return sum(sum(v.values()) for v in g.values())
def new_gaps(before, after):
out = OrderedDict()
for day, s in after.items():
b = before.get(day, {})
worse = {r: k - b.get(r, 0) for r, k in s.items() if k > b.get(r, 0)}
if worse:
out[day] = worse
return out
def blackout_hit(team, lv):
days = set(leave_days(team, lv))
for bdays, reason in team["blackout"]:
hit = sorted(days & bdays)
if hit:
return hit, reason
return None, None
def suggest(team, current, lv, max_shift=15):
"""Nearest shift of the same request (same calendar length) that keeps coverage."""
length = (lv["end"] - lv["start"]).days
want = len(leave_days(team, lv))
base = gaps(team, current)
first, last = team["days"][0], team["days"][-1]
for shift in sorted(range(-max_shift, max_shift + 1), key=lambda x: (abs(x), x)):
if shift == 0:
continue
cand = dict(lv, start=lv["start"] + dt.timedelta(days=shift), end=lv["start"] + dt.timedelta(days=shift + length))
if cand["start"] < first or cand["end"] > last:
continue
if len(leave_days(team, cand)) < want or blackout_hit(team, cand)[0]:
continue
if not new_gaps(base, gaps(team, current + [cand])):
return cand
return None
def trimmed(team, current, lv):
"""Longest part of a blackout-hitting request, before the first blackout day, that keeps coverage."""
hit, _ = blackout_hit(team, lv)
days = [x for x in leave_days(team, lv) if x < hit[0]]
base = gaps(team, current)
best = None
for i in range(len(days)):
for j in range(len(days) - 1, i - 1, -1):
if best and j - i + 1 <= len(leave_days(team, best)):
break
cand = dict(lv, start=days[i], end=days[j])
if not new_gaps(base, gaps(team, current + [cand])):
best = cand
break
return best
def plan(team, leaves):
findings = []
def add(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
approved = [l for l in leaves if l["status"] == "approved"]
requests = [l for l in leaves if l["status"] == "requested"]
used = defaultdict(float)
for l in approved:
if l["type"] == "vacation":
used[l["person"]] += charge(team, l)
g_approved = gaps(team, approved)
for day, s in g_approved.items():
add("high", "LV01", "%s %s: approved leave already leaves %s short" % (
day.isoformat(), DAYS[day.weekday()].title(), ", ".join("%s by %d" % (r, k) for r, k in sorted(s.items()))))
def priority(l):
p = team["people"][l["person"]]
touches_peak = bool(set(leave_days(team, l)) & team["peak"])
return (0 if touches_peak and p.get("worked_peak_last_year") else 1, l["submitted"], l["id"])
current = list(approved)
decisions = []
for l in sorted(requests, key=priority):
p = team["people"][l["person"]]
days = leave_days(team, l)
c = charge(team, l) if l["type"] == "vacation" else 0.0
dec = {"leave": l, "workdays": len(days), "charge": c, "decision": "approve", "reason": "", "suggestion": None}
if not days:
dec.update(decision="no workdays", reason="falls on holidays / non-working days only")
elif l["type"] == "vacation" and p["allowance"] and p["taken"] + used[l["person"]] + c > p["allowance"] + 1e-9:
left = p["allowance"] - p["taken"] - used[l["person"]]
dec.update(decision="over allowance", reason="needs %g days, %g left" % (c, max(left, 0)))
else:
hit, reason = blackout_hit(team, l)
if hit:
dec.update(decision="blackout", reason="%s (%s)" % (reason, ", ".join(x.strftime("%d %b") for x in hit)))
else:
ng = new_gaps(gaps(team, current), gaps(team, current + [l]))
if ng:
roles = sorted({r for s in ng.values() for r in s})
dec.update(decision="conflict", reason="%s short on %s" % (
"/".join(roles), ", ".join(x.strftime("%d %b") for x in ng)))
if dec["decision"] == "approve":
current.append(l)
used[l["person"]] += c
elif dec["decision"] in ("conflict", "blackout"):
s = suggest(team, current, l)
if s:
dec["suggestion"] = "%s to %s" % (s["start"].strftime("%a %d %b"), s["end"].strftime("%a %d %b"))
elif dec["decision"] == "blackout":
t = trimmed(team, current, l)
if t:
dec["suggestion"] = "shorten to %s - %s (%d workday(s), back before the blackout)" % (
t["start"].strftime("%a %d %b"), t["end"].strftime("%a %d %b"), len(leave_days(team, t)))
decisions.append(dec)
approved_peak = defaultdict(int)
for l in current:
approved_peak[l["person"]] += len(set(leave_days(team, l)) & team["peak"])
for dec in decisions:
l = dec["leave"]
p = team["people"][l["person"]]
if dec["decision"] != "approve" and p.get("worked_peak_last_year") and set(leave_days(team, l)) & team["peak"]:
lucky = [n for n, k in approved_peak.items() if k and not team["people"][n].get("worked_peak_last_year")]
add("medium", "LV05", "%s worked the peak last year but this request is %s while %s get peak days off; "
"consider swapping." % (l["person"], dec["decision"], ", ".join(sorted(lucky)) or "nobody"))
g_final = gaps(team, current)
allowance = []
for n, p in team["people"].items():
planned_req = sum(dec["charge"] for dec in decisions if dec["leave"]["person"] == n and dec["decision"] == "approve")
appr = used[n] - planned_req
left = p["allowance"] - p["taken"] - used[n] if p["allowance"] else None
allowance.append({"person": n, "allowance": p["allowance"], "taken_before": p["taken"], "approved": appr,
"recommended": planned_req, "left": left, "peak_days_off": approved_peak.get(n, 0)})
if p["allowance"] and p["taken"] + appr > p["allowance"] + 1e-9:
add("high", "LV02", "%s's approved leave exceeds the allowance by %g days (%g + %g taken of %g); "
"confirm carry-over or unpaid leave with HR." % (n, p["taken"] + appr - p["allowance"], p["taken"],
appr, p["allowance"]))
cm = p.get("carryover_max")
if left is not None and cm is not None and left > float(cm):
add("low", "LV06", "%s will still have %g days left (carry-over limit %g); %g day(s) are lost unless booked."
% (n, left, float(cm), left - float(cm)))
for role, k in sorted(team["coverage"].items()):
if role == "any":
continue
holders = [n for n, p in team["people"].items() if role in p["roles"]]
if len(holders) <= k:
add("medium", "LV03", "Only %d people can cover %s (minimum %d): any absence by %s breaks coverage; "
"cross-train a backup." % (len(holders), role, k, ", ".join(holders)))
elif len(holders) == k + 1:
add("low", "LV03", "%s has one spare person (%s for a minimum of %d); two absences at once break it."
% (role, ", ".join(holders), k))
for day, s in g_final.items():
if day not in g_approved:
add("high", "LV01", "%s still short after decisions: %s" % (day, s))
for dec in decisions:
if dec["decision"] in ("conflict", "blackout"):
l = dec["leave"]
add("medium", "LV04", "%s %s-%s: %s (%s)%s" % (
l["person"], l["start"].strftime("%d %b"), l["end"].strftime("%d %b"), dec["decision"], dec["reason"],
("; offer " + dec["suggestion"]) if dec["suggestion"] else "; no gap-free alternative within 15 days"))
elif dec["decision"] == "over allowance":
l = dec["leave"]
add("medium", "LV02", "%s %s-%s: %s; approve a shorter trip or agree unpaid days" % (
l["person"], l["start"].strftime("%d %b"), l["end"].strftime("%d %b"), dec["reason"]))
thin = []
off = off_map(team, current)
for day in team["days"]:
if not is_workday(team, day):
continue
working = [n for n, p in team["people"].items() if n not in off.get(day, set()) and person_works(team, p, day)]
if len(working) == need_for(team, day).get("any", 0):
thin.append(day)
if thin:
add("info", "LV07", "No slack on %d day(s) (%s): one sick day breaks coverage; line up a backup." % (
len(thin), ", ".join(x.strftime("%d %b") for x in thin)))
order = {"high": 0, "medium": 1, "low": 2, "info": 3}
findings.sort(key=lambda f: (order[f["severity"]], f["code"]))
return {"team": team.get("team", "Team"), "period": team["period"], "gaps_approved": g_approved,
"gaps_final": g_final, "decisions": decisions, "allowance": allowance, "findings": findings,
"current": current, "thin": thin}
# ----------------------------------------------------------------------------- report
def grid(team, leaves_final, all_leaves):
days = [x for x in team["days"] if x.weekday() < 5 or DAYS[x.weekday()] in team["workdays"]]
final_ids = {l["id"] for l in leaves_final}
cell = defaultdict(dict)
for l in all_leaves:
if l["status"] == "declined":
continue
for day in leave_days(team, l):
if l["id"] in final_ids:
c = TYPE_CODE[l["type"]] if l["status"] == "approved" else TYPE_CODE[l["type"]].lower()
else:
c = "x"
if l["half"]:
c = "h" if c != "x" else "x"
cell[l["person"]][day] = c
width = max([len(n) for n in team["people"]] + [len("cover " + r) for r in team["coverage"]]) + 2
months = " " * width + "".join(x.strftime("%b")[0] if i == 0 or x.month != days[i - 1].month
else " " for i, x in enumerate(days))
L = [months, " " * width + "".join(str(x.day // 10) for x in days),
" " * width + "".join(str(x.day % 10) for x in days),
" " * width + "".join(DAYS[x.weekday()][0].upper() for x in days)]
for n, p in team["people"].items():
row = []
for x in days:
if x in team["holidays"]:
row.append("H")
elif not person_works(team, p, x):
row.append("-")
else:
row.append(cell[n].get(x, "."))
L.append(n.ljust(width) + "".join(row))
off = off_map(team, leaves_final)
for role in sorted(team["coverage"]):
row = []
for x in days:
if not is_workday(team, x):
row.append(" ")
continue
s = shortfall(team, x, off.get(x, set()))
row.append("!" if role in s else "+")
L.append(("cover " + role).ljust(width) + "".join(row))
return L
def report(team, leaves, r):
L = ["# Leave coverage plan: %s (%s)" % (r["team"], r["period"].replace("..", " to ")), ""]
need = ", ".join("%s %d" % (k, v) for k, v in sorted(team["coverage"].items()))
L.append("Minimum cover per workday: %s. Holidays (closed): %s." % (
need, ", ".join(x.strftime("%d %b") for x in sorted(team["holidays"])) or "none"))
n_appr = sum(1 for dec in r["decisions"] if dec["decision"] == "approve")
verdict = ("approved leave already breaks coverage" if r["gaps_approved"] else
"over allowance on approved leave" if any(f["code"] == "LV02" and f["severity"] == "high" for f in r["findings"])
else "covered")
L += ["", "**Verdict: %s** - %d of %d open requests can be approved without a gap; %d need a decision." % (
verdict, n_appr, len(r["decisions"]), len(r["decisions"]) - n_appr), ""]
L += ["## Requests (in decision order: worked peak last year first, then first come first served)", "",
"| # | Person | Dates | Workdays | Type | Submitted | Decision | Why | Alternative |",
"|---|---|---|---|---|---|---|---|---|"]
for i, dec in enumerate(r["decisions"], 1):
l = dec["leave"]
L.append("| %d | %s | %s - %s%s | %s | %s | %s | %s | %s | %s |" % (
i, l["person"], l["start"].strftime("%a %d %b"), l["end"].strftime("%a %d %b"), " (half)" if l["half"] else "",
("%g" % (0.5 * dec["workdays"])) if l["half"] else dec["workdays"], l["type"], l["submitted"][:10],
dec["decision"], dec["reason"] or "-", dec["suggestion"] or "-"))
L += ["", "## Calendar (approved + recommended)", "", "```"] + grid(team, r["current"], leaves) + [
"```", "V/T/S/P/U/O approved vacation/training/sick/parental/unpaid/other, lowercase = request recommended "
"for approval, h = half day, x = request not approved, H = holiday, - = not a working day, "
"+ / ! = role covered / short.", ""]
L += ["## Allowance (days)", "", "| Person | Allowance | Taken before | Approved here | Recommended | Left | Peak days off |",
"|---|---|---|---|---|---|---|"]
for a in r["allowance"]:
L.append("| %s | %g | %g | %g | %g | %s | %d |" % (a["person"], a["allowance"], a["taken_before"], a["approved"],
a["recommended"], "-" if a["left"] is None else "%g" % a["left"],
a["peak_days_off"]))
L += ["", "## Findings", ""]
if not r["findings"]:
L.append("- none")
for f in r["findings"]:
L.append("- **%s %s** %s" % (f["severity"].upper(), f["code"], f["message"]))
return "\n".join(L) + "\n"
# ----------------------------------------------------------------------------- self-test
def self_test():
import os
import tempfile
cases = []
def ok(name, cond, detail=""):
cases.append((name, bool(cond), detail))
base = {"team": "T", "period": "2026-12-14..2026-12-25", "holidays": ["2026-12-25"], "peak_days": "2026-12-21..2026-12-24",
"coverage": {"lead": 1, "dev": 1, "any": 2},
"people": [{"name": "A", "roles": ["lead", "dev"], "allowance": 10, "taken": 0},
{"name": "B", "roles": ["dev"], "allowance": 10, "taken": 9},
{"name": "C", "roles": ["lead"], "allowance": 10, "taken": 0, "worked_peak_last_year": True},
{"name": "D", "roles": ["dev"], "days": ["mon", "tue"], "allowance": 5, "taken": 0}]}
t = load_team(base)
ok("weekends and holidays are not workdays", not is_workday(t, d("2026-12-19")) and not is_workday(t, d("2026-12-25")))
sf = shortfall(t, d("2026-12-16"), {"B", "C", "D"})
ok("one person fills one slot (A cannot be lead and dev)", sf.get("any") == 1 and sf.get("lead", 0) + sf.get("dev", 0) == 1, sf)
ok("matching finds A=dev, C=lead", shortfall(t, d("2026-12-16"), {"B", "D"}) == {})
ok("part-timer only counts on own days", shortfall(t, d("2026-12-17"), {"A", "B"}) == {"dev": 1, "any": 1}, shortfall(t, d("2026-12-17"), {"A", "B"}))
lv = {"id": 1, "person": "D", "start": d("2026-12-14"), "end": d("2026-12-20"), "type": "vacation",
"status": "requested", "submitted": "2026-11-01", "half": False, "notes": ""}
ok("leave days skip non-working days", len(leave_days(t, lv)) == 2)
ok("half day charges 0.5", charge(t, dict(lv, half=True)) == 1.0)
d0 = tempfile.mkdtemp()
csvp = os.path.join(d0, "l.csv")
with open(csvp, "w") as f:
f.write("person,start,end,type,status,submitted,half_day,notes\n"
"A,2026-12-21,2026-12-22,vacation,approved,2026-10-01,,\n"
"C,2026-12-21,2026-12-21,vacation,requested,2026-11-10,,\n"
"B,2026-12-14,2026-12-16,vacation,requested,2026-10-05,,\n"
"D,2026-12-15,2026-12-15,training,requested,2026-11-01,,\n")
leaves = load_leave(csvp, t)
r = plan(t, leaves)
by = {(dec["leave"]["person"]): dec for dec in r["decisions"]}
ok("C conflicts with A's approved leave (no lead)", by["C"]["decision"] == "conflict", by["C"])
ok("conflict gets an alternative date", by["C"]["suggestion"] is not None, by["C"])
ok("B over allowance (3 days, 1 left)", by["B"]["decision"] == "over allowance", by["B"])
ok("D training approved", by["D"]["decision"] == "approve", by["D"])
ok("worked-peak person decided first", r["decisions"][0]["leave"]["person"] == "C", [x["leave"]["person"] for x in r["decisions"]])
codes = {f["code"] for f in r["findings"]}
ok("LV03 single point of failure for lead", "LV03" in codes, codes)
ok("LV05 fairness flag for C", "LV05" in codes, codes)
ok("no gaps from approved leave", not r["gaps_approved"])
with open(csvp, "a") as f:
f.write("C,2026-12-21,2026-12-21,sick,approved,2026-12-01,,\n")
r2 = plan(t, load_leave(csvp, t))
ok("LV01 gap in approved leave", any(f["code"] == "LV01" and f["severity"] == "high" for f in r2["findings"]))
t2 = load_team(dict(base, blackout=[{"dates": "2026-12-16", "reason": "release"}]))
r3 = plan(t2, load_leave(csvp, t2))
ok("blackout precedes coverage", any(x["decision"] == "blackout" for x in r3["decisions"]) or
all(x["leave"]["person"] != "B" or x["decision"] == "over allowance" for x in r3["decisions"]))
lvb = dict(lv, person="A", start=d("2026-12-16"), end=d("2026-12-16"))
ok("blackout_hit detects overlap", blackout_hit(t2, lvb)[0] == [d("2026-12-16")])
try:
load_team(dict(base, coverage={"qa": 1}))
ok("unknown coverage role rejected", False)
except InputError:
ok("unknown coverage role rejected", True)
rep = report(t, leaves, r)
ok("report has grid and verdict", "Verdict" in rep and "cover lead" in rep)
for name, good, detail in cases:
print("%s %s%s" % ("PASS" if good else "FAIL", name, "" if good else " -> %r" % (detail,)))
passed = sum(1 for _, g, _ in cases if g)
print("%d/%d passed" % (passed, len(cases)))
return 0 if passed == len(cases) else 1
def to_json(r):
def conv(o):
if isinstance(o, dt.date):
return o.isoformat()
if isinstance(o, dict):
return {(k.isoformat() if isinstance(k, dt.date) else k): conv(v) for k, v in o.items()}
if isinstance(o, (list, tuple)):
return [conv(x) for x in o]
return o
return json.dumps(conv(r), indent=2)
def main(argv=None):
ap = argparse.ArgumentParser(description="Plan team leave against minimum coverage.")
ap.add_argument("team", nargs="?")
ap.add_argument("leave", nargs="?")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not (a.team and a.leave):
ap.print_usage(sys.stderr)
return 2
try:
with open(a.team, encoding="utf-8") as f:
team = load_team(json.load(f))
leaves = load_leave(a.leave, team)
r = plan(team, leaves)
except (InputError, OSError, json.JSONDecodeError, KeyError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
print(to_json(r) if a.json else report(team, leaves, r), end="\n" if a.json else "")
bad = r["gaps_approved"] or any(f["code"] == "LV02" and f["severity"] == "high" for f in r["findings"])
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
```
전체 프롬프트를 보려면 로그인하세요
Continue with:
By logging in, you agree to our Terms of Use and Privacy Policy
사용법
이 프롬프트는 business와 함께 사용하도록 설계되었습니다. 위의 프롬프트 내용을 복사하여 원하는 AI 도구에 붙여넣으세요.
최상의 결과를 얻으려면 자리 표시자(대괄호 또는 대문자로 표시)를 특정 요구 사항으로 사용자 지정할 수 있습니다.
토론
댓글 0개