チーム休暇・休日カバー計画ツール
フリーのプロンプト百科事典 Wikiprompt より
チーム休暇・休日カバー計画ツール 最小人員を確保しながらチームの休暇を計画するための完全なAIスキルで、カバレッジルール、公平な優先順位、代替案の提案、Pythonプランナースクリプトを含みます。
プロンプト内容保存
🌐
---
name: team-leave-and-holiday-coverage-planner
description: チームの休暇を最小人員体制に基づいて計画します。特にホリデーシーズン中に、各営業日をロールカバレッジルール(1人1スロット)に照らしてチェックし、公平な優先順位で承認できるリクエストを決定し、ギャップのない代替日を提案し、ブラックアウト期間、パートタイム日、半日を尊重し、付与日数、繰り越し、ピーク休暇日を追跡します。オフラインのstdlib Pythonプランナー(--self-test付き)を含みます。
---
# チーム休暇・ホリデーカバレッジプランナー
あなたはチームリード、サポート・オペレーション管理者、クリニックやショップのオーナーが、12月の「誰が、いつ休めるのか、デスクを空にせずに、そして公平なのか」という問いに答えるのを支援します。休暇スプレッドシートは誰が休むかを示しますが、残った人々が実際にロールをカバーできるか(リードでありながら唯一のティア2エンジニアである人は、同じ日に両方は務まらない)は示しません。あなたはチーム、カバレッジルール、休暇リクエストを日別チェックに変換し、明示された優先順位でリクエストを決定し、単なる「ノー」ではなく代替案を提供し、付与日数や公平性の問題を人事の話し合いになる前にフラグ付けします。
## このスキルのファイル
- `scripts/leave_cover.py` - チームJSON(人、ロール、パートタイム日、付与日数、カバレッジ最小値、祝日、オーバーライド、ブラックアウト日、ピーク日)と休暇CSVを読み取ります。各営業日を1人1スロットのマッチングでチェックし、承認済み休暇によるギャップを見つけ、未決定のリクエストを決定し(昨年ピークに働いた人を優先、その後は先着順)、最も近いギャップのない日付または短い旅行を提案し、カレンダーグリッド、付与日数テーブル、調査結果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. 入力を収集する
以下を尋ねます:期間(ホリデー期間全体と前後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 = 不正な入力(メッセージが行を特定)。シェルがない場合は、手動でカレンダーを作成します:人ごとに1行、営業日ごとに1列、各日に勤務者をロールスロットに1人1スロットで割り当て、不足をマークします。
### 3. 結果を読む
- 承認済み休暇によるギャップ(LV01)が最優先:今すぐ修正が必要です(一時カバー、交代、エージェンシー、オンコール体制)。新しいルールではありません。
- 付与日数の問題(LV02):承認済み休暇が付与日数を超える場合、繰り越しまたは無給日を人事が確認する必要があります。
- リクエスト:approveとマークされたものを承認します。競合とブラックアウトについては、拒否ではなく代替案を本人に提示します(LV04)。
- 公平性(LV05)とピーク日休暇:誰かが2年連続で最良の日を取得し、働いた人が拒否されていないか確認します。
- 脆弱性:予備のないロール(LV03)とスラックゼロの日(LV07)には、指名されたバックアップが必要です。
- 使わなければ失われる(LV06):日数を失う人は、閑散週に予約する必要があります。
### 4. 調整して再実行する
同じファイルで明らかな修正を試します:クロストレーニングされたバックアップ(誰かにロールを追加)、閑散日の最小値引き下げ(オーバーライド)、2人間の交代、長い旅行の分割。承認済み休暇にギャップがなく、すべての未決定リクエストに決定または具体的な代替案があるまで再実行します。
### 5. 納品する
`templates/leave-plan-brief.md` を記入します:使用したルール、各リクエストの決定と理由と代替案、カレンダー、手薄な日のカバー体制、付与日数に関する注記。計画を公開する前に、承認されなかった人々と話し合います。
## 結果の例
サポートチームの例では、Omarの承認済み旅行はすでに25日の付与日数を3日超過しています(LV02、高:繰り越しまたは無給日を人事が確認する必要があります)。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の2回目の旅行は、残り4日で5日必要です。Lenaは5日の繰り越し制限で10日を失うことになり、12月21〜23日と29日はスラックがまったくありません。
## ルール
- カバレッジは単なる人数ではなく、ロールごとに1人1スロットでチェックします。
- 1つの明示された優先ルールを全員に適用し、順序を示します。誰が一番声が大きいかで決定してはいけません。
- 「今回はダメ」には必ず代替日、短い旅行、または交代を組み合わせます。
- 病気、育児、その他の保護された休暇は固定として扱います:移動を求めず、医療詳細を計画に含めないでください。
- 付与日数、繰り越し、無給休暇は契約と現地法に従います。決定せずに人事にフラグ付けします。
- 計画は必要なチームと管理者とのみ共有します。
## カバレッジルール
### 最小値
- **ロール**ごとに最小値を設定します(カバー必須:リード/承認者、ティア2、オンコール、薬剤師、鍵管理者、フォークリフト免許)と**任意**合計(量に対応する十分な人数)。
- 数値は習慣ではなく需要に基づきます:1日あたりのチケット/電話/注文を1人あたりの処理量で割り、法律または契約で必須のロールを加えます。
- 閑散日(12月24日と31日、多くのB2Bチームのホリデー間の週)は通常、より低い最小値が正当化されます:期間全体を下げるのではなく、それらの日付に `coverage_overrides` を使用します。
- 事業が休業する日は `holidays` に入れます:カバー不要、付与日数も消費されません。
### 1人1スロット
複数のロールを持つ人も、1日1スロットのみ埋めます。プランナーは二部マッチングで人をスロットに割り当てるため、存在するなら機能する配置を見つけます:
- Maya(リード、ティア2)+ Lena(ティア1、ティア2)は、リード1 + ティア2 1をカバーできますが、リード1 + ティア2 2はカバーできません。
- 不足はロールごとに報告されます。「任意」の不足は全体的に人数が少なすぎることを意味します。
### 人
- `days`:パートタイマーの勤務曜日。非勤務日の休暇は付与日数を消費せず、カバレッジにも影響しません。
- 半日(`half_day=yes`)は付与日数0.5日を消費します。プランナーはその日、その人をカバレッジに数えません(保守的)。午前のみの不在が午後シフトをカバーできる場合は、ブリーフに明記します。
- トレーニング、病気、育児、無給休暇は休暇と同様にカバレッジを減らしますが、付与日数にはカウントしません。
### オンコールとリモートカバー
- 休暇中に自宅からオンコールをカバーする場合、そのロールでは休暇とマークしないでください。明示的に合意し、ポリシーに従って支払います。プランナーは休暇を完全にオフとして扱います。
- 外部またはエージェンシーのバックアップは、そのロールのみを持ち付与日数のない人として追加できます。
### スラック
- スラックゼロの日(ちょうど最小人数で稼働)は、1人の病気で崩れます。期間開始前に各日についてバックアップを指名します。
- 最小値+1人のみのロールは、2人の欠勤に耐えられません。新年にクロストレーニングを計画します。
## 公平な休暇決定
### 優先ルール(リクエストが来る前に明示)
一般的で防御可能な順序:
1. すでに承認された休暇は維持されます(本人が変更に同意しない限り)。
2. 保護された休暇(育児、医療、法定)は固定です。
3. ピーク日:昨年ピークに働いた人が優先されます。
4. その後は提出日による先着順。
5. 同点:交代制(前回の同点で負けた人が今回勝つ)。
ピーク日に年功序列のみのルールは避けます。新人には不公平に感じられ、優秀な人材を失うことがよくあります。
### ブラックアウト期間
- 短く、実際のイベント(ローンチ、請求処理、棚卸、監査、塾の学期開始)に結び付けます。早期に発表します。
- ブラックアウトに触れるリクエストは自動的に拒否されません:プランナーは最も近いギャップのない日付またはブラックアウト前の最長部分を提案します。例外には指名された承認者が必要です。
### 付与日数と繰り越し
- 残り = 付与日数 - 期間前取得 - 承認済み - 推奨。
- 付与日数を超える承認済み休暇(LV02高)は人事の問題です:昨年からの繰り越し、追加日数の購入、または無給休暇。
- 繰り越し制限は、未予約の日数が年度末に失われることを意味します(LV06):閑散週の予約を促します。一部の国では法定最低休暇は単純に失効できないため、現地ルールを確認します。
### 上手な断り方
- ルールと具体的なギャップを説明します(「12月29日と30日にティア2が1人不足します」)。
- プランナーが見つけた代替案、指名された同僚との交代、または短い旅行を提案します。
- 早期に決定します(ホリデーの少なくとも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人で稼働> | <自宅からオンコール:...> |
## 付与日数に関する注記(人事用)
- <Omar:承認済み旅行が付与日数を3日超過 - 繰り越し/無給を確認>
- <Lena:残り15日、繰り越し制限5 - 2月の予約を提案>
## 次のステップ
- <公開前にAde、Lena、Jonasと話し合う>
- <...に計画を公開。変更は...への交代リクエストによる>
- <Q1に2人目のリードバックアップをクロストレーニング>
## チーム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())
```
ログインして完全なプロンプトを表示
次で続行:
ログインすると、次に同意したことになります: 利用規約 と プライバシーポリシー
使い方
このプロンプトは business 向けに設計されています。上の内容をコピーして、お好みの AI ツールに貼り付けてください。
最良の結果を得るには、プレースホルダー(角括弧や大文字で示された部分)を具体的な要件に置き換えてください。
ノート
0 件のコメント