Files
keinasystem/document/14_マスタードキュメント_分配計画編.md
Akira 466eef128c 分配計画機能を実装
施肥計画の圃場を配置場所単位でグループ化し、グループ×肥料の集計表を
表示・PDF出力できる機能を追加。

- Backend: DistributionPlan/Group/GroupField モデル (migration 0003)
- API: GET/POST/PUT/DELETE/PDF (/api/fertilizer/distribution/)
- Frontend: 一覧・新規作成・編集画面 (/distribution)
- Navbar に分配計画メニューを追加
- 集計プレビューはクライアントサイド計算(API不要)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 09:43:20 +09:00

242 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# マスタードキュメント:分配計画機能
> **作成**: 2026-03-02
> **最終更新**: 2026-03-02
> **対象機能**: 分配計画(施肥計画の圃場をグループ化し配置場所単位で集計)
> **実装状況**: 実装完了
---
## 概要
施肥計画FertilizationPlanで決めた圃場ごとの袋数を、**実際に肥料を配置する場所の単位**でまとめる機能。
「田中エリアにはA肥料12袋・B肥料6袋を持っていく」という単位で計画・PDF出力できる。
### 機能スコープ
| IN実装済み | OUT対象外 |
|---|---|
| 施肥計画を元に圃場をカスタムグループに割り当て | 購入管理 |
| グループ×肥料の集計表(画面表示) | 実施記録 |
| PDF出力グループ合計行圃場サブ行 | |
| グループの順序変更・名前変更 | |
---
## データモデル
### DistributionPlan分配計画
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| fertilization_plan | FK(FertilizationPlan) | CASCADE | |
| name | varchar(200) | required | 計画名 |
| created_at / updated_at | datetime | auto | |
- `ordering = ['-fertilization_plan__year', 'name']`
- 1つの施肥計画に対して複数の分配計画を作れるOneToOneではなくFK
### DistributionGroup分配グループ
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| distribution_plan | FK(DistributionPlan) | CASCADE | |
| name | varchar(100) | required | グループ名 |
| order | PositiveIntegerField | default=0 | 表示順 |
- `unique_together = [['distribution_plan', 'name']]` → 同一計画内でグループ名重複不可
- `ordering = ['order', 'id']`
### DistributionGroupFieldグループ圃場割り当て
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| distribution_plan | FK(DistributionPlan) | CASCADE | 一意制約のために冗長保持 |
| group | FK(DistributionGroup) | CASCADE | |
| field | FK(fields.Field) | PROTECT | 圃場 |
- `unique_together = [['distribution_plan', 'field']]` → 1圃場=1グループ/1計画
- `ordering = ['field__display_order', 'field__id']`
---
## API エンドポイント
すべて JWT 認証(`Authorization: Bearer <token>`)。
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/fertilizer/distribution/?year={year}` | 一覧(年度フィルタ) |
| POST | `/api/fertilizer/distribution/` | 新規作成 |
| GET | `/api/fertilizer/distribution/{id}/` | 詳細groups/entries/unassigned込み |
| PUT | `/api/fertilizer/distribution/{id}/` | 更新groups全置換 |
| DELETE | `/api/fertilizer/distribution/{id}/` | 削除 |
| GET | `/api/fertilizer/distribution/{id}/pdf/` | PDF出力application/pdf |
### 一覧レスポンスDistributionPlanListSerializer
```json
{
"id": 1,
"name": "2025年コシヒカリ 分配計画",
"fertilization_plan_id": 3,
"fertilization_plan_name": "2025年コシヒカリ施肥計画",
"year": 2025,
"variety_name": "コシヒカリ",
"crop_name": "米",
"group_count": 3,
"field_count": 12,
"created_at": "...",
"updated_at": "..."
}
```
### 詳細レスポンスDistributionPlanReadSerializer
```json
{
"id": 1,
"name": "2025年コシヒカリ 分配計画",
"fertilization_plan": {
"id": 3,
"name": "2025年コシヒカリ施肥計画",
"year": 2025,
"variety_name": "コシヒカリ",
"crop_name": "米",
"fertilizers": [{"id": 1, "name": "一発肥料"}],
"entries": [{"field": 5, "fertilizer": 1, "bags": "2.40"}]
},
"groups": [
{
"id": 10,
"name": "田中エリア",
"order": 0,
"fields": [{"id": 5, "name": "田中上", "area_tan": "1.2000"}]
}
],
"unassigned_fields": [{"id": 7, "name": "未割り当て圃場", "area_tan": "0.5000"}]
}
```
### 書き込みリクエストPOST/PUT
```json
{
"name": "2025年コシヒカリ 分配計画",
"fertilization_plan_id": 3,
"groups": [
{"name": "田中エリア", "order": 0, "field_ids": [5, 6]},
{"name": "奥地エリア", "order": 1, "field_ids": [7]}
]
}
```
PUT は groups を全削除→再作成する全置換方式。
---
## フロントエンド画面
### 分配計画一覧 `/distribution`
- 年度セレクタ(`localStorage distributionYear` で保持)
- テーブル: 計画名・施肥計画・作物/品種・グループ数・圃場数
- アクション: PDF・編集・削除
- 削除エラー: インラインバナー(確認なし・失敗したらバナー表示)
### 分配計画編集 `/distribution/new` / `/distribution/[id]/edit`
**共通コンポーネント**: `frontend/src/app/distribution/_components/DistributionEditPage.tsx`
#### State構成
```typescript
// 基本情報
const [name, setName] = useState('')
const [fertilizationPlanId, setFertilizationPlanId] = useState<number|''>('')
// 施肥計画詳細(施肥計画選択後に取得)
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null)
// ローカルグループtempId で管理、保存時にサーバーへ送信)
const [groups, setGroups] = useState<LocalGroup[]>([])
// LocalGroup = { tempId: string, name: string, order: number, fieldIds: number[], isRenamingName?: string }
```
#### UI構成
1. **計画基本情報**: 計画名テキスト + 施肥計画セレクタ
2. **グループ割り当て**:
- 新規グループ追加(名前入力 + 追加ボタン)
- グループカード(↑↓順序変更・鉛筆名前変更・×削除)
- グループ内圃場(×解除)+ 肥料別袋数をインライン表示
- 未割り当て圃場セクション(グループ選択ドロップダウンで割り当て)
3. **集計プレビュー**: グループ×肥料マトリクス(リアルタイム・サーバー通信なし)
---
## PDF 出力
`GET /api/fertilizer/distribution/{id}/pdf/`
- WeasyPrint既存施肥計画PDFと同じ仕組み
- テンプレート: `backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html`
- フォーマット: A4横向き
- 内容:
- ★グループ合計行(太字・緑背景)
- 圃場サブ行(小フォント・灰色背景)
- 肥料列合計・総合計
- ファイル名: `distribution_{year}_{plan_id}.pdf`
---
## ファイル構成
### Backend
```
backend/apps/fertilizer/
├── models.py # DistributionPlan/Group/GroupField 追加migration 0003
├── serializers.py # Distribution* シリアライザ追加
├── views.py # DistributionPlanViewSet 追加
├── urls.py # router.register('distribution', ...) 追加
├── admin.py # DistributionPlan/Group の admin 登録
└── templates/fertilizer/
└── distribution_pdf.html # A4横 PDF テンプレート
```
### Frontend
```
frontend/src/app/distribution/
├── page.tsx # 一覧ページ
├── new/page.tsx # 新規作成(ラッパー)
├── [id]/edit/page.tsx # 編集(ラッパー)
└── _components/DistributionEditPage.tsx # 編集共通コンポーネント
```
---
## 注意点
### 集計は全クライアントサイド計算
集計プレビューは API を呼ばず、`fertPlanDetail.entries``groups.fieldIds` からクライアントで計算する。
PDF生成時のみサーバーサイドで同じ計算を実施。
### PUT の全置換方式
PUT 時は `groups.all().delete()` → 再作成。部分更新は非対応。
### 未割り当て圃場の扱い
- 施肥計画に含まれる圃場のうちグループに割り当てられていないものは「未割り当て」として表示
- PDF にも「未割り当て」グループとして出力される(ゼロの場合は出力なし)
### エラー表示方針
施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。