토론

팀 휴가 및 공휴일 커버리지 플래너

Wikiprompt, 무료 프롬프트 백과사전에서

Fatih Kadir Akın

2026년 11월 2일

팀 휴가 및 공휴일 커버리지 플래너 팀 휴가 계획을 위한 완전한 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 도구에 붙여넣으세요.

최상의 결과를 얻으려면 자리 표시자(대괄호 또는 대문자로 표시)를 특정 요구 사항으로 사용자 지정할 수 있습니다.

참고 자료

분류:business| prompts.chat| leave-planning| coverage-scheduling

토론